Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55646ba1b4 | ||
|
|
8abf1397a6 | ||
|
|
e0e101ef28 | ||
|
|
33f87f8db3 | ||
|
|
5e2d4e7aaf | ||
|
|
4d1ec8ba7e | ||
|
|
609c71a3f7 | ||
|
|
2b189e3186 | ||
|
|
a1b82ecdc4 | ||
|
|
d09fda13c5 | ||
|
|
457e1147e4 | ||
|
|
dd7a23a281 | ||
|
|
d16802c164 | ||
|
|
8d8fbeb816 | ||
|
|
433ccdcf86 | ||
|
|
0fb0d088a7 | ||
|
|
827ae912e0 | ||
|
|
d79ac74fdd | ||
|
|
1b0a05bbcf | ||
|
|
2bb4f86d04 | ||
|
|
c8643c080d | ||
|
|
4551d73e76 | ||
|
|
9b4860cfc2 | ||
|
|
da6fa25e60 | ||
|
|
4d38530361 | ||
|
|
59e566885b | ||
|
|
2358f97e5c | ||
|
|
03f1509de7 | ||
|
|
66fe3b763a | ||
|
|
aec4c83eca | ||
|
|
4d81f9c723 | ||
|
|
ee70001d44 | ||
|
|
f467e7c09f |
@@ -0,0 +1,13 @@
|
||||
# Dada 开发约定
|
||||
|
||||
## 适用范围
|
||||
|
||||
本约定自当前“Dada P0-A Windows 单机内测版”第一版开发起立即生效,适用于任务计划中的每个 `TASK-*`,也适用于第一版完成后的问题修改和相关工单。
|
||||
|
||||
## 开发约定
|
||||
|
||||
- 每个 `TASK-*` 或后续工单都是独立交付单元。完成测试与回归后,必须形成范围明确的本地 Git 提交,推送到 `intelligrow` 组织仓库的同名 Gitea 远端分支,并记录任务号、完整提交 SHA、远端分支和远端哈希核对结果。不得等第一版全部开发完成后再统一提交;本地未提交、推送失败、仅推送个人命名空间或远端哈希不一致时,不得宣告当前任务完成,也不得开始下一任务。
|
||||
- 修复任何工单必须在独立的 git worktree 中进行;开工前先用 `git worktree add` 创建专属工作目录,避免污染主工作区、便于多工单并行。修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。
|
||||
- 处理任何工单必须先检查并使用适用的 Superpowers Skill;在分析、提问、制定计划或改代码前,至少先启用 `using-superpowers`,并按任务性质继续使用 `systematic-debugging`、`test-driven-development`、`using-git-worktrees`、`verification-before-completion` 等相关技能。若判断没有适用技能,必须简短说明原因后再继续。
|
||||
- 新功能需求类工单必须先使用 Superpowers 的 `brainstorming` 技能帮助澄清目标、约束和方案,再进入计划或实现;缺陷类工单必须先使用 `systematic-debugging` 技能复现问题并分析 root cause,再开始修复,禁止在根因未明确时直接改代码。
|
||||
- 实现或修复工单完成后,必须继续按 Superpowers 收尾流程执行验证、代码审查、PR/合并准备和工作区清理;通常应依次使用 `verification-before-completion`、`requesting-code-review`、`finishing-a-development-branch` 等适用技能,在完成这些流程前不得声称工单已结束。
|
||||
+7
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档版本 | 1.1 |
|
||||
| 文档版本 | 1.2 |
|
||||
| 适用产品版本 | PRD v1.10,P0-A 本机测试需求冻结稿 |
|
||||
| 功能索引版本 | FeatureSummary 1.1 |
|
||||
| 当前交付 | 仅 P0-A Windows 本机测试版 |
|
||||
@@ -55,6 +55,12 @@ P1 的本地底图上传、空白画布、普通贴纸关键词搜索、公开
|
||||
|
||||
Gitea Actions 是已确定的工程交付组件,不是 PRD 产品依赖,也不计入上述三项外部发布阻塞。当前电脑的 Windows 自托管 Runner 在隔离工作目录注册并跑通无真实凭据流水线之前,本地工程初始化和测试脚本可继续,但正式 CI 和 WP-0 工程完成不得宣告。
|
||||
|
||||
### 1.6 任务级 Git/Gitea 交付协议
|
||||
|
||||
`tasks.md` 中每个 `TASK-*` 是最小工程交付单元。每个任务在开始实现前建立独立的 `codex/<wp-task>` 分支;完成 Red、Green、所属门禁、回归和脱敏扫描后,必须形成只包含该任务及其必要文档同步的本地 Git 提交,并推送到项目指定的 Gitea 组织仓库同名远端分支。当前项目的目标组织命名空间固定为 `intelligrow`,个人命名空间中的仓库或分支不作为任务交付位置。
|
||||
|
||||
任务交付必须记录 `task_id`、完整提交 SHA、Gitea 仓库、远端分支和远端哈希核对结果;记录不得包含凭据。推送后必须通过远端引用确认 Gitea 分支哈希与本地提交完全一致。只有本地测试 Green、必要真实/人工门禁齐全且远端核对通过,任务才可从 `green` 或 `externally_blocked` 进入 `passed`。仅有工作区改动、本地提交、推送失败、推送到个人命名空间或远端哈希不一致时,均不得宣告任务完成、工作包完成,也不得开始下一个 `TASK-*`。
|
||||
|
||||
## 2. 技术架构与选型
|
||||
|
||||
### 2.1 固定技术基线
|
||||
|
||||
+3
-3
@@ -2,13 +2,13 @@
|
||||
|
||||
| 文档项 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档版本 | 1.0 |
|
||||
| 文档版本 | 1.1 |
|
||||
| 产品范围 | P0-A Windows 单机版 |
|
||||
| 设计状态 | 低保真交互与界面规范 |
|
||||
| 主要画布 | Windows 当前稳定版 Chrome / Edge,1920 × 1080 优先验收 |
|
||||
| 产品需求权威 | PRD.md v1.10 |
|
||||
| 功能与状态导航 | FeatureSummary.md 1.1 |
|
||||
| 工程与接口约束 | DevelopmentPlan.md 1.1 |
|
||||
| 工程与接口约束 | DevelopmentPlan.md 1.2 |
|
||||
| 本阶段产出 | 页面、布局、组件、状态、文案、焦点顺序和可访问性规范 |
|
||||
| 本阶段不产出 | 高保真视觉稿、前端代码、移动端、P0-B、部署人员 CLI |
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| --- | --- |
|
||||
| PRD.md | 31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565 |
|
||||
| FeatureSummary.md | 6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844 |
|
||||
| DevelopmentPlan.md | 7C579E9DF8D00DA9333F587D05FC966123437EE68575D87D73C903C1487FA9D6 |
|
||||
| DevelopmentPlan.md | 76CCC786E910F3E503921AEF5B9BD22976E364184BA8C0062CDCF1C5F376AC0A |
|
||||
|
||||
## 文档使用规则
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dada/shared-contracts": "workspace:*",
|
||||
"@fastify/multipart": "10.1.0",
|
||||
"@fastify/swagger": "9.8.1",
|
||||
"@sinclair/typebox": "0.34.52",
|
||||
"better-sqlite3": "13.0.1",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface AmapAdapter {
|
||||
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>;
|
||||
}
|
||||
|
||||
export class MockAmapAdapter implements AmapAdapter {
|
||||
readonly calls: Array<{ latitude: number; longitude: number }> = [];
|
||||
|
||||
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
|
||||
this.calls.push({ ...coordinates });
|
||||
return {
|
||||
formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`,
|
||||
serviceMode: "mock" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2152
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
export const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||
const forbiddenSummaryKeys = new Set([
|
||||
"absolute_path", "api_key", "body", "code_hmac", "content", "credential",
|
||||
"email", "image", "image_content", "password", "path", "prompt", "secret",
|
||||
"session_token", "verification_code", "whitelist",
|
||||
]);
|
||||
const forbiddenSummaryKeyFragments = [
|
||||
"content", "credential", "email", "image", "password", "path", "prompt", "secret", "token",
|
||||
];
|
||||
const safeStringPattern = /^[A-Za-z0-9_.:@-]{1,160}$/;
|
||||
|
||||
function isSafeSummaryValue(value: unknown, depth: number): boolean {
|
||||
if (depth > 3) return false;
|
||||
if (value === null || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isSafeInteger(value);
|
||||
if (typeof value === "string") return safeStringPattern.test(value) && !value.includes("@");
|
||||
if (Array.isArray(value)) return value.length <= 20 && value.every((entry) => isSafeSummaryValue(entry, depth + 1));
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entries = Object.entries(value);
|
||||
return entries.length <= 32 && entries.every(([key, entry]) => (
|
||||
auditRefPattern.test(key)
|
||||
&& !forbiddenSummaryKeys.has(key.toLowerCase())
|
||||
&& !forbiddenSummaryKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||
&& isSafeSummaryValue(entry, depth + 1)
|
||||
));
|
||||
}
|
||||
|
||||
export function isSafeAuditRef(value: unknown) {
|
||||
return typeof value === "string" && auditRefPattern.test(value) ? 1 : 0;
|
||||
}
|
||||
|
||||
export function isSafeAuditSummaryJson(value: unknown) {
|
||||
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
|
||||
try {
|
||||
return isSafeSummaryValue(JSON.parse(value), 0) ? 1 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeAuditSummary(value: Record<string, unknown> | null) {
|
||||
if (value === null) return null;
|
||||
const serialized = JSON.stringify(value);
|
||||
if (isSafeAuditSummaryJson(serialized) !== 1) throw new Error("audit_summary_sensitive_or_invalid");
|
||||
return serialized;
|
||||
}
|
||||
|
||||
function toMilliseconds(value: string | number | null | undefined, fallback: number) {
|
||||
if (typeof value === "number" && Number.isSafeInteger(value)) return value;
|
||||
const parsed = Date.parse(String(value));
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function createAdminOperationTable(database: BetterSqlite3.Database) {
|
||||
database.exec(`
|
||||
CREATE TABLE admin_operation_logs (
|
||||
log_id TEXT PRIMARY KEY,
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||
actor_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(actor_ref) = 1),
|
||||
operation_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(operation_type) = 1),
|
||||
target_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_type) = 1),
|
||||
target_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_ref) = 1),
|
||||
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||
before_summary TEXT CHECK (before_summary IS NULL OR dada_audit_summary_is_safe(before_summary) = 1),
|
||||
after_summary TEXT CHECK (after_summary IS NULL OR dada_audit_summary_is_safe(after_summary) = 1),
|
||||
occurred_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function installAdminOperationTriggers(database: BetterSqlite3.Database) {
|
||||
database.exec(`
|
||||
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
|
||||
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
|
||||
CREATE TRIGGER admin_operation_logs_no_update
|
||||
BEFORE UPDATE ON admin_operation_logs
|
||||
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
CREATE TRIGGER admin_operation_logs_no_delete
|
||||
BEFORE DELETE ON admin_operation_logs
|
||||
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
|
||||
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
`);
|
||||
}
|
||||
|
||||
export function ensureAdminOperationAuditSchema(database: BetterSqlite3.Database, fallbackNow: number) {
|
||||
const table = database.prepare(`
|
||||
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'admin_operation_logs'
|
||||
`).get() as { sql: string } | undefined;
|
||||
if (!table) {
|
||||
createAdminOperationTable(database);
|
||||
installAdminOperationTriggers(database);
|
||||
return;
|
||||
}
|
||||
if (table.sql.includes("dada_audit_summary_is_safe") && table.sql.includes(String(auditRetentionMilliseconds))) {
|
||||
installAdminOperationTriggers(database);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>;
|
||||
const current = columns.some((column) => column.name === "actor_type");
|
||||
const rows = current
|
||||
? database.prepare("SELECT * FROM admin_operation_logs").all() as Array<Record<string, string | number | null>>
|
||||
: database.prepare("SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs").all() as Array<Record<string, string | number | null>>;
|
||||
const migrate = database.transaction(() => {
|
||||
database.exec(`
|
||||
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
|
||||
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
|
||||
ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_before_wp1_06;
|
||||
`);
|
||||
createAdminOperationTable(database);
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const occurredAt = toMilliseconds(current ? row.occurred_at! : row.created_at!, fallbackNow);
|
||||
const expiresAt = current ? toMilliseconds(row.expires_at!, fallbackNow) : occurredAt + auditRetentionMilliseconds;
|
||||
if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid");
|
||||
const beforeSummary = current && typeof row.before_summary === "string"
|
||||
? serializeAuditSummary(JSON.parse(row.before_summary) as Record<string, unknown>)
|
||||
: null;
|
||||
const afterSummary = current
|
||||
? typeof row.after_summary === "string" ? serializeAuditSummary(JSON.parse(row.after_summary) as Record<string, unknown>) : null
|
||||
: serializeAuditSummary({ legacy_outcome: String(row.outcome) });
|
||||
insert.run(
|
||||
row.log_id,
|
||||
current ? row.actor_type : "system",
|
||||
current ? row.actor_ref : "managed_storage_migration",
|
||||
current ? row.operation_type : row.operation,
|
||||
current ? row.target_type : "legacy_operation",
|
||||
row.target_ref,
|
||||
current ? row.result : String(row.outcome).startsWith("denied") ? "failed" : "succeeded",
|
||||
beforeSummary,
|
||||
afterSummary,
|
||||
occurredAt,
|
||||
expiresAt,
|
||||
);
|
||||
}
|
||||
database.exec("DROP TABLE admin_operation_logs_before_wp1_06");
|
||||
installAdminOperationTriggers(database);
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
|
||||
function createPrivateAccessTable(database: BetterSqlite3.Database) {
|
||||
database.exec(`
|
||||
CREATE TABLE private_content_access_logs (
|
||||
log_id TEXT PRIMARY KEY,
|
||||
actor_ref TEXT NOT NULL CHECK (length(actor_ref) = 36 AND dada_audit_ref_is_safe(actor_ref) = 1),
|
||||
subject_ref TEXT NOT NULL CHECK (length(subject_ref) = 36 AND dada_audit_ref_is_safe(subject_ref) = 1),
|
||||
target_ref TEXT NOT NULL CHECK (length(target_ref) = 36 AND dada_audit_ref_is_safe(target_ref) = 1),
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('image', 'prompt')),
|
||||
occurred_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function installPrivateAccessTriggers(database: BetterSqlite3.Database) {
|
||||
database.exec(`
|
||||
DROP TRIGGER IF EXISTS private_content_access_logs_no_update;
|
||||
DROP TRIGGER IF EXISTS private_content_access_logs_no_delete;
|
||||
CREATE TRIGGER private_content_access_logs_no_update
|
||||
BEFORE UPDATE ON private_content_access_logs
|
||||
WHEN dada_allow_privacy_purge() <> 1
|
||||
OR OLD.subject_ref <> dada_privacy_purge_subject()
|
||||
OR NEW.actor_ref <> OLD.actor_ref
|
||||
OR NEW.content_type <> OLD.content_type
|
||||
OR NEW.occurred_at <> OLD.occurred_at
|
||||
OR NEW.expires_at <> OLD.expires_at
|
||||
OR NEW.subject_ref = OLD.subject_ref
|
||||
OR NEW.target_ref = OLD.target_ref
|
||||
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
|
||||
CREATE TRIGGER private_content_access_logs_no_delete
|
||||
BEFORE DELETE ON private_content_access_logs
|
||||
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
|
||||
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
|
||||
`);
|
||||
}
|
||||
|
||||
export function ensurePrivateAccessAuditSchema(database: BetterSqlite3.Database) {
|
||||
const table = database.prepare(`
|
||||
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'private_content_access_logs'
|
||||
`).get() as { sql: string } | undefined;
|
||||
if (!table) {
|
||||
createPrivateAccessTable(database);
|
||||
installPrivateAccessTriggers(database);
|
||||
return;
|
||||
}
|
||||
if (table.sql.includes(String(auditRetentionMilliseconds))) {
|
||||
installPrivateAccessTriggers(database);
|
||||
return;
|
||||
}
|
||||
const rows = database.prepare("SELECT * FROM private_content_access_logs").all() as Array<Record<string, string | number>>;
|
||||
const migrate = database.transaction(() => {
|
||||
database.exec(`
|
||||
DROP TRIGGER IF EXISTS private_content_access_logs_no_update;
|
||||
DROP TRIGGER IF EXISTS private_content_access_logs_no_delete;
|
||||
ALTER TABLE private_content_access_logs RENAME TO private_content_access_logs_before_wp1_06;
|
||||
`);
|
||||
createPrivateAccessTable(database);
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO private_content_access_logs (
|
||||
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const occurredAt = toMilliseconds(row.occurred_at, 0);
|
||||
const expiresAt = toMilliseconds(row.expires_at, 0);
|
||||
if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid");
|
||||
insert.run(row.log_id, row.actor_ref, row.subject_ref, row.target_ref, row.content_type, occurredAt, expiresAt);
|
||||
}
|
||||
database.exec("DROP TABLE private_content_access_logs_before_wp1_06");
|
||||
installPrivateAccessTriggers(database);
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type CreditErrorCode =
|
||||
| "credit_account_not_found"
|
||||
| "credit_generation_not_found"
|
||||
| "credit_insufficient"
|
||||
| "credit_invariant_failed"
|
||||
| "credit_operation_conflict"
|
||||
| "credit_request_invalid";
|
||||
|
||||
export class CreditError extends Error {
|
||||
readonly code: CreditErrorCode;
|
||||
|
||||
constructor(code: CreditErrorCode) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import {
|
||||
auditRetentionMilliseconds,
|
||||
ensureAdminOperationAuditSchema,
|
||||
isSafeAuditRef,
|
||||
isSafeAuditSummaryJson,
|
||||
serializeAuditSummary,
|
||||
} from "./audit-policy.js";
|
||||
import { CreditError } from "./credit-errors.js";
|
||||
|
||||
export { CreditError } from "./credit-errors.js";
|
||||
export type { CreditErrorCode } from "./credit-errors.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
interface CreditAccountRow {
|
||||
available_balance: number;
|
||||
reserved_balance: number;
|
||||
updated_at: number;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface LedgerRow {
|
||||
amount: number;
|
||||
available_after: number;
|
||||
available_before: number;
|
||||
created_at: number;
|
||||
entry_status: "succeeded" | "frozen" | "committed" | "released";
|
||||
entry_type: "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||
ledger_id: string;
|
||||
model_id: string | null;
|
||||
reason: string | null;
|
||||
reference_id: string | null;
|
||||
reference_type: "registration" | "generation" | "admin_adjustment" | null;
|
||||
reserved_after: number;
|
||||
reserved_before: number;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface ReservationRow {
|
||||
amount: number;
|
||||
generation_id: string;
|
||||
model_id: string;
|
||||
status: "reserved" | "committed" | "released";
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function encodeCursor(row: Pick<LedgerRow, "created_at" | "ledger_id">) {
|
||||
return Buffer.from(JSON.stringify([row.created_at, row.ledger_id]), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined) {
|
||||
if (!cursor) return undefined;
|
||||
try {
|
||||
const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||
if (!Array.isArray(value) || value.length !== 2 || !Number.isSafeInteger(value[0]) || typeof value[1] !== "string") {
|
||||
throw new Error("cursor_invalid");
|
||||
}
|
||||
return { createdAt: value[0] as number, ledgerId: value[1] };
|
||||
} catch {
|
||||
throw new CreditError("credit_request_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export class CreditService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(input: { clock?: () => number; databasePath: string }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("synchronous = FULL");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
|
||||
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
|
||||
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
readAccount(userId: string) {
|
||||
const row = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(userId) as CreditAccountRow | undefined;
|
||||
if (!row) throw new CreditError("credit_account_not_found");
|
||||
return {
|
||||
availableBalance: row.available_balance,
|
||||
reservedBalance: row.reserved_balance,
|
||||
updatedAt: iso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
listLedger(input: {
|
||||
cursor?: string;
|
||||
eventType?: LedgerRow["entry_type"];
|
||||
from?: string;
|
||||
limit?: number;
|
||||
to?: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const account = this.readAccount(input.userId);
|
||||
const limit = input.limit ?? 20;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new CreditError("credit_request_invalid");
|
||||
const cursor = decodeCursor(input.cursor);
|
||||
const conditions = ["user_id = ?"];
|
||||
const values: Array<number | string> = [input.userId];
|
||||
if (input.eventType) {
|
||||
conditions.push("entry_type = ?");
|
||||
values.push(input.eventType);
|
||||
}
|
||||
if (input.from) {
|
||||
const from = Date.parse(input.from);
|
||||
if (!Number.isFinite(from)) throw new CreditError("credit_request_invalid");
|
||||
conditions.push("created_at >= ?");
|
||||
values.push(from);
|
||||
}
|
||||
if (input.to) {
|
||||
const to = Date.parse(input.to);
|
||||
if (!Number.isFinite(to)) throw new CreditError("credit_request_invalid");
|
||||
conditions.push("created_at <= ?");
|
||||
values.push(to);
|
||||
}
|
||||
if (cursor) {
|
||||
conditions.push("(created_at < ? OR (created_at = ? AND ledger_id < ?))");
|
||||
values.push(cursor.createdAt, cursor.createdAt, cursor.ledgerId);
|
||||
}
|
||||
const rows = this.database.prepare(`
|
||||
SELECT * FROM credit_ledger WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY created_at DESC, ledger_id DESC LIMIT ?
|
||||
`).all(...values, limit + 1) as LedgerRow[];
|
||||
const hasMore = rows.length > limit;
|
||||
const page = rows.slice(0, limit);
|
||||
return {
|
||||
account,
|
||||
entries: page.map((row) => ({
|
||||
amount: row.amount,
|
||||
availableAfter: row.available_after,
|
||||
availableBefore: row.available_before,
|
||||
createdAt: iso(row.created_at),
|
||||
entryId: row.ledger_id,
|
||||
entryType: row.entry_type,
|
||||
modelId: row.model_id,
|
||||
reason: row.reason,
|
||||
referenceId: row.reference_id,
|
||||
referenceType: row.reference_type,
|
||||
reservedAfter: row.reserved_after,
|
||||
reservedBefore: row.reserved_before,
|
||||
status: row.entry_status,
|
||||
})),
|
||||
nextCursor: hasMore && page.length > 0 ? encodeCursor(page.at(-1)!) : null,
|
||||
};
|
||||
}
|
||||
|
||||
reserveGeneration(input: {
|
||||
creditCost: number;
|
||||
generationId: string;
|
||||
modelId: string;
|
||||
operationKey: string;
|
||||
userId: string;
|
||||
}) {
|
||||
if (!Number.isSafeInteger(input.creditCost) || input.creditCost <= 0 || !input.modelId || !input.operationKey) {
|
||||
throw new CreditError("credit_request_invalid");
|
||||
}
|
||||
return this.immediate(() => {
|
||||
const replay = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?")
|
||||
.get(input.operationKey) as LedgerRow | undefined;
|
||||
if (replay) {
|
||||
if (replay.user_id !== input.userId || replay.reference_id !== input.generationId
|
||||
|| replay.entry_type !== "generation_reserve" || replay.amount !== -input.creditCost
|
||||
|| replay.model_id !== input.modelId) {
|
||||
throw new CreditError("credit_operation_conflict");
|
||||
}
|
||||
return { availableBalance: replay.available_after, reservedBalance: replay.reserved_after, status: "reserved" as const };
|
||||
}
|
||||
const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(input.userId) as CreditAccountRow | undefined;
|
||||
if (!account) throw new CreditError("credit_account_not_found");
|
||||
if (account.available_balance < input.creditCost) throw new CreditError("credit_insufficient");
|
||||
if (!this.tableExists("generation_jobs")) throw new CreditError("credit_generation_not_found");
|
||||
const generation = this.database.prepare(`
|
||||
SELECT generation_id FROM generation_jobs
|
||||
WHERE generation_id = ? AND owner_id = ? AND status IN ('queued', 'running')
|
||||
`).get(input.generationId, input.userId);
|
||||
if (!generation) throw new CreditError("credit_generation_not_found");
|
||||
const existing = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
|
||||
.get(input.generationId) as ReservationRow | undefined;
|
||||
if (existing) throw new CreditError("credit_operation_conflict");
|
||||
const availableAfter = account.available_balance - input.creditCost;
|
||||
const reservedAfter = account.reserved_balance + input.creditCost;
|
||||
const now = this.clock();
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ?
|
||||
WHERE user_id = ? AND available_balance = ? AND reserved_balance = ?
|
||||
`).run(availableAfter, reservedAfter, now, input.userId, account.available_balance, account.reserved_balance);
|
||||
if (changed.changes !== 1) throw new CreditError("credit_invariant_failed");
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_reservations (generation_id, user_id, model_id, amount, status, created_at, finalized_at)
|
||||
VALUES (?, ?, ?, ?, 'reserved', ?, NULL)
|
||||
`).run(input.generationId, input.userId, input.modelId, input.creditCost, now);
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs SET model_id = ?, confirmed_credit_cost = ?, reserved_credits = ?, final_credit_state = NULL
|
||||
WHERE generation_id = ?
|
||||
`).run(input.modelId, input.creditCost, input.creditCost, input.generationId);
|
||||
this.insertLedger({
|
||||
amount: -input.creditCost,
|
||||
availableAfter,
|
||||
availableBefore: account.available_balance,
|
||||
createdAt: now,
|
||||
entryStatus: "frozen",
|
||||
entryType: "generation_reserve",
|
||||
modelId: input.modelId,
|
||||
operationKey: input.operationKey,
|
||||
reason: null,
|
||||
referenceId: input.generationId,
|
||||
referenceType: "generation",
|
||||
reservedAfter,
|
||||
reservedBefore: account.reserved_balance,
|
||||
userId: input.userId,
|
||||
});
|
||||
this.insertOutbox("generation_credit_reserved", input.generationId, input.operationKey, { amount: input.creditCost, user_id: input.userId }, now);
|
||||
return { availableBalance: availableAfter, reservedBalance: reservedAfter, status: "reserved" as const };
|
||||
});
|
||||
}
|
||||
|
||||
finalizeGeneration(input: {
|
||||
generationId: string;
|
||||
operationKey: string;
|
||||
outcome: "succeeded" | "failed" | "rejected";
|
||||
}) {
|
||||
if (!input.operationKey) throw new CreditError("credit_request_invalid");
|
||||
return this.immediate(() => {
|
||||
const reservation = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
|
||||
.get(input.generationId) as ReservationRow | undefined;
|
||||
if (!reservation) throw new CreditError("credit_generation_not_found");
|
||||
if (reservation.status !== "reserved") {
|
||||
const replay = this.database.prepare(`
|
||||
SELECT * FROM credit_ledger WHERE reference_id = ? AND entry_type IN ('generation_commit', 'generation_release')
|
||||
ORDER BY created_at DESC, ledger_id DESC LIMIT 1
|
||||
`).get(input.generationId) as LedgerRow | undefined;
|
||||
if (!replay) throw new CreditError("credit_invariant_failed");
|
||||
return {
|
||||
availableBalance: replay.available_after,
|
||||
creditState: reservation.status,
|
||||
reservedBalance: replay.reserved_after,
|
||||
status: "finalized" as const,
|
||||
};
|
||||
}
|
||||
const operation = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?").get(input.operationKey) as LedgerRow | undefined;
|
||||
if (operation) throw new CreditError("credit_operation_conflict");
|
||||
const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(reservation.user_id) as CreditAccountRow | undefined;
|
||||
if (!account || account.reserved_balance < reservation.amount) throw new CreditError("credit_invariant_failed");
|
||||
const committed = input.outcome === "succeeded";
|
||||
const creditState = committed ? "committed" as const : "released" as const;
|
||||
const availableAfter = committed ? account.available_balance : account.available_balance + reservation.amount;
|
||||
const reservedAfter = account.reserved_balance - reservation.amount;
|
||||
if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_invariant_failed");
|
||||
const now = this.clock();
|
||||
this.database.prepare(`
|
||||
UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? WHERE user_id = ?
|
||||
`).run(availableAfter, reservedAfter, now, reservation.user_id);
|
||||
this.database.prepare(`
|
||||
UPDATE credit_reservations SET status = ?, finalized_at = ? WHERE generation_id = ? AND status = 'reserved'
|
||||
`).run(creditState, now, input.generationId);
|
||||
this.database.prepare("UPDATE generation_jobs SET final_credit_state = ? WHERE generation_id = ?")
|
||||
.run(creditState, input.generationId);
|
||||
this.insertLedger({
|
||||
amount: committed ? -reservation.amount : reservation.amount,
|
||||
availableAfter,
|
||||
availableBefore: account.available_balance,
|
||||
createdAt: now,
|
||||
entryStatus: committed ? "committed" : "released",
|
||||
entryType: committed ? "generation_commit" : "generation_release",
|
||||
modelId: reservation.model_id,
|
||||
operationKey: input.operationKey,
|
||||
reason: null,
|
||||
referenceId: input.generationId,
|
||||
referenceType: "generation",
|
||||
reservedAfter,
|
||||
reservedBefore: account.reserved_balance,
|
||||
userId: reservation.user_id,
|
||||
});
|
||||
this.insertOutbox(committed ? "generation_credit_committed" : "generation_credit_released", input.generationId, input.operationKey, { outcome: input.outcome }, now);
|
||||
return { availableBalance: availableAfter, creditState, reservedBalance: reservedAfter, status: "finalized" as const };
|
||||
});
|
||||
}
|
||||
|
||||
adjustAvailable(input: {
|
||||
adjustmentId: string;
|
||||
adminId: string;
|
||||
amount: number;
|
||||
idempotencyKey: string;
|
||||
reason: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const reason = input.reason.trim();
|
||||
if (!Number.isSafeInteger(input.amount) || input.amount === 0 || !reason || reason.length > 500
|
||||
|| input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200
|
||||
|| !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) {
|
||||
throw new CreditError("credit_request_invalid");
|
||||
}
|
||||
const idempotencyKeyDigest = createHash("sha256").update(input.idempotencyKey, "utf8").digest("hex");
|
||||
const requestHash = createHash("sha256")
|
||||
.update(JSON.stringify([input.adjustmentId, input.adminId, input.userId, input.amount, reason]), "utf8")
|
||||
.digest("hex");
|
||||
return this.immediate(() => {
|
||||
type Receipt = {
|
||||
adjustment_id: string;
|
||||
admin_id: string | null;
|
||||
available_after: number;
|
||||
idempotency_key_digest: string | null;
|
||||
request_hash: string;
|
||||
reserved_after: number;
|
||||
user_id: string | null;
|
||||
};
|
||||
const receiptByKey = this.database.prepare(`
|
||||
SELECT * FROM credit_adjustment_receipts
|
||||
WHERE admin_id = ? AND user_id = ? AND idempotency_key_digest = ?
|
||||
`).get(input.adminId, input.userId, idempotencyKeyDigest) as Receipt | undefined;
|
||||
if (receiptByKey) {
|
||||
if (receiptByKey.request_hash !== requestHash) throw new CreditError("credit_operation_conflict");
|
||||
return {
|
||||
adjustmentId: receiptByKey.adjustment_id,
|
||||
availableBalance: receiptByKey.available_after,
|
||||
reservedBalance: receiptByKey.reserved_after,
|
||||
status: "adjusted" as const,
|
||||
};
|
||||
}
|
||||
const receipt = this.database.prepare("SELECT * FROM credit_adjustment_receipts WHERE adjustment_id = ?")
|
||||
.get(input.adjustmentId) as Receipt | undefined;
|
||||
if (receipt) {
|
||||
if (receipt.request_hash !== requestHash || receipt.admin_id !== input.adminId || receipt.user_id !== input.userId
|
||||
|| receipt.idempotency_key_digest !== idempotencyKeyDigest) {
|
||||
throw new CreditError("credit_operation_conflict");
|
||||
}
|
||||
return {
|
||||
adjustmentId: input.adjustmentId,
|
||||
availableBalance: receipt.available_after,
|
||||
reservedBalance: receipt.reserved_after,
|
||||
status: "adjusted" as const,
|
||||
};
|
||||
}
|
||||
const admin = this.database.prepare(`
|
||||
SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id
|
||||
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
|
||||
`).get(input.adminId);
|
||||
if (!admin) throw new CreditError("credit_account_not_found");
|
||||
const target = this.database.prepare(`
|
||||
SELECT c.* FROM credit_accounts c JOIN users u ON u.user_id = c.user_id
|
||||
WHERE c.user_id = ? AND u.role = 'user' AND u.status <> 'deleted'
|
||||
`).get(input.userId) as CreditAccountRow | undefined;
|
||||
if (!target) throw new CreditError("credit_account_not_found");
|
||||
const availableAfter = target.available_balance + input.amount;
|
||||
if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_request_invalid");
|
||||
const now = this.clock();
|
||||
this.database.prepare("UPDATE credit_accounts SET available_balance = ?, updated_at = ? WHERE user_id = ?")
|
||||
.run(availableAfter, now, input.userId);
|
||||
const ledgerId = this.insertLedger({
|
||||
amount: input.amount,
|
||||
availableAfter,
|
||||
availableBefore: target.available_balance,
|
||||
createdAt: now,
|
||||
entryStatus: "succeeded",
|
||||
entryType: "admin_adjustment",
|
||||
modelId: null,
|
||||
operationKey: `admin_adjustment:${createHash("sha256").update(`${input.adminId}\0${input.userId}\0${idempotencyKeyDigest}`, "utf8").digest("hex")}`,
|
||||
reason,
|
||||
referenceId: input.adjustmentId,
|
||||
referenceType: "admin_adjustment",
|
||||
reservedAfter: target.reserved_balance,
|
||||
reservedBefore: target.reserved_balance,
|
||||
userId: input.userId,
|
||||
});
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'super_admin', ?, 'credit_adjustment', 'user_credit_account', ?, 'succeeded', ?, ?, ?, ?)
|
||||
`).run(
|
||||
randomUUID(), input.adminId, input.userId,
|
||||
serializeAuditSummary({ available_balance: target.available_balance, reserved_balance: target.reserved_balance }),
|
||||
serializeAuditSummary({ adjustment_amount: input.amount, available_balance: availableAfter, reserved_balance: target.reserved_balance }),
|
||||
now, now + auditRetentionMilliseconds,
|
||||
);
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_adjustment_receipts (
|
||||
adjustment_id, admin_id, user_id, idempotency_key_digest, request_hash,
|
||||
ledger_id, available_after, reserved_after, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.adjustmentId, input.adminId, input.userId, idempotencyKeyDigest, requestHash,
|
||||
ledgerId, availableAfter, target.reserved_balance, now,
|
||||
);
|
||||
return {
|
||||
adjustmentId: input.adjustmentId,
|
||||
availableBalance: availableAfter,
|
||||
reservedBalance: target.reserved_balance,
|
||||
status: "adjusted" as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private immediate<T>(action: () => T): T {
|
||||
if (this.database.inTransaction) return action();
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
this.database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private insertLedger(input: {
|
||||
amount: number;
|
||||
availableAfter: number;
|
||||
availableBefore: number;
|
||||
createdAt: number;
|
||||
entryStatus: LedgerRow["entry_status"];
|
||||
entryType: LedgerRow["entry_type"];
|
||||
modelId: string | null;
|
||||
operationKey: string;
|
||||
reason: string | null;
|
||||
referenceId: string | null;
|
||||
referenceType: LedgerRow["reference_type"];
|
||||
reservedAfter: number;
|
||||
reservedBefore: number;
|
||||
userId: string;
|
||||
}) {
|
||||
const ledgerId = randomUUID();
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_ledger (
|
||||
ledger_id, user_id, operation_key, entry_type, amount,
|
||||
available_before, available_after, reserved_before, reserved_after, created_at,
|
||||
reference_type, reference_id, model_id, reason, entry_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
ledgerId, input.userId, input.operationKey, input.entryType, input.amount,
|
||||
input.availableBefore, input.availableAfter, input.reservedBefore, input.reservedAfter, input.createdAt,
|
||||
input.referenceType, input.referenceId, input.modelId, input.reason, input.entryStatus,
|
||||
);
|
||||
return ledgerId;
|
||||
}
|
||||
|
||||
private insertOutbox(topic: string, aggregateId: string, operationKey: string, payload: Record<string, unknown>, now: number) {
|
||||
this.database.prepare(`
|
||||
INSERT INTO outbox_events (
|
||||
event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at
|
||||
) VALUES (?, ?, ?, 'generation', ?, ?, 'pending', ?, NULL)
|
||||
`).run(randomUUID(), operationKey, topic, aggregateId, JSON.stringify(payload), now);
|
||||
}
|
||||
|
||||
private tableExists(name: string) {
|
||||
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
}
|
||||
|
||||
private ensureColumn(table: string, column: string, definition: string) {
|
||||
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
if (!this.tableExists("credit_accounts") || !this.tableExists("credit_ledger")) {
|
||||
throw new Error("credit_schema_unavailable");
|
||||
}
|
||||
this.ensureColumn("credit_ledger", "reference_type", "TEXT");
|
||||
this.ensureColumn("credit_ledger", "reference_id", "TEXT");
|
||||
this.ensureColumn("credit_ledger", "model_id", "TEXT");
|
||||
this.ensureColumn("credit_ledger", "reason", "TEXT");
|
||||
this.ensureColumn("credit_ledger", "entry_status", "TEXT NOT NULL DEFAULT 'succeeded'");
|
||||
if (this.tableExists("generation_jobs")) {
|
||||
this.ensureColumn("generation_jobs", "model_id", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "model_config_version", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "confirmed_credit_cost", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "reserved_credits", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.ensureColumn("generation_jobs", "final_credit_state", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "finished_at", "INTEGER");
|
||||
}
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS credit_reservations (
|
||||
generation_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
model_id TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL CHECK (amount > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('reserved', 'committed', 'released')),
|
||||
created_at INTEGER NOT NULL,
|
||||
finalized_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS credit_reservations_user_status ON credit_reservations(user_id, status, created_at);
|
||||
CREATE TABLE IF NOT EXISTS credit_adjustment_receipts (
|
||||
adjustment_id TEXT PRIMARY KEY,
|
||||
admin_id TEXT REFERENCES users(user_id),
|
||||
user_id TEXT REFERENCES users(user_id),
|
||||
idempotency_key_digest TEXT CHECK (idempotency_key_digest IS NULL OR length(idempotency_key_digest) = 64),
|
||||
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||
ledger_id TEXT NOT NULL UNIQUE REFERENCES credit_ledger(ledger_id),
|
||||
available_after INTEGER NOT NULL,
|
||||
reserved_after INTEGER NOT NULL CHECK (reserved_after >= 0),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
operation_key TEXT NOT NULL UNIQUE,
|
||||
topic TEXT NOT NULL,
|
||||
aggregate_type TEXT NOT NULL,
|
||||
aggregate_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'published')),
|
||||
created_at INTEGER NOT NULL,
|
||||
published_at INTEGER
|
||||
);
|
||||
DROP TRIGGER IF EXISTS credit_ledger_shape_guard;
|
||||
CREATE TRIGGER credit_ledger_shape_guard BEFORE INSERT ON credit_ledger
|
||||
WHEN
|
||||
NEW.entry_status NOT IN ('succeeded', 'frozen', 'committed', 'released')
|
||||
OR (NEW.entry_type = 'admin_adjustment' AND (
|
||||
NEW.reference_type <> 'admin_adjustment' OR NEW.reference_id IS NULL OR trim(COALESCE(NEW.reason, '')) = ''
|
||||
))
|
||||
OR (NEW.entry_type IN ('generation_reserve', 'generation_commit', 'generation_release') AND (
|
||||
NEW.reference_type <> 'generation' OR NEW.reference_id IS NULL OR NEW.model_id IS NULL
|
||||
))
|
||||
BEGIN SELECT RAISE(ABORT, 'credit_ledger_shape_invalid'); END;
|
||||
`);
|
||||
this.ensureColumn("credit_adjustment_receipts", "admin_id", "TEXT REFERENCES users(user_id)");
|
||||
this.ensureColumn("credit_adjustment_receipts", "user_id", "TEXT REFERENCES users(user_id)");
|
||||
this.ensureColumn("credit_adjustment_receipts", "idempotency_key_digest", "TEXT");
|
||||
this.database.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS credit_adjustment_idempotency
|
||||
ON credit_adjustment_receipts(admin_id, user_id, idempotency_key_digest)
|
||||
WHERE admin_id IS NOT NULL AND user_id IS NOT NULL AND idempotency_key_digest IS NOT NULL;
|
||||
`);
|
||||
ensureAdminOperationAuditSchema(this.database, this.clock());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type GenerationSubmissionErrorCode =
|
||||
| "generation_blocked"
|
||||
| "generation_idempotency_conflict"
|
||||
| "generation_not_found"
|
||||
| "generation_request_invalid"
|
||||
| "generation_storage_unavailable"
|
||||
| "model_config_stale"
|
||||
| "reference_invalid";
|
||||
|
||||
export class GenerationSubmissionError extends Error {
|
||||
readonly code: GenerationSubmissionErrorCode;
|
||||
readonly errorCategory: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | undefined;
|
||||
readonly latest: { configVersion: number; creditCost: number; modelId: string } | undefined;
|
||||
readonly storage: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number } | undefined;
|
||||
|
||||
constructor(
|
||||
code: GenerationSubmissionErrorCode,
|
||||
options: {
|
||||
errorCategory?: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid";
|
||||
latest?: { configVersion: number; creditCost: number; modelId: string };
|
||||
storage?: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number };
|
||||
} = {},
|
||||
) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
this.errorCategory = options.errorCategory;
|
||||
this.latest = options.latest;
|
||||
this.storage = options.storage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import type { CreditService } from "./credits.js";
|
||||
import { GenerationSubmissionError } from "./generation-submission-errors.js";
|
||||
import type { ManagedStorage, StagedManagedFile } from "./managed-storage.js";
|
||||
import {
|
||||
defaultCanvasState,
|
||||
defaultProjectName,
|
||||
historyLimit,
|
||||
normalizePrompt,
|
||||
projectLimit,
|
||||
projectRatios,
|
||||
ratioPixels,
|
||||
stableJson,
|
||||
type ProjectRatio,
|
||||
} from "./projects.js";
|
||||
import { classifyCapacity } from "./storage-policy.js";
|
||||
|
||||
export { GenerationSubmissionError } from "./generation-submission-errors.js";
|
||||
|
||||
const idempotencyPattern = /^[A-Za-z0-9_-]{32,200}$/;
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export interface GenerationModelSnapshot {
|
||||
configSetVersion: number;
|
||||
configVersion: number;
|
||||
contractValidationStatus: "verified" | "unverified";
|
||||
creditCost: number;
|
||||
enabled: boolean;
|
||||
modelId: string;
|
||||
promptMaxLength: number;
|
||||
referenceLimits: { maxFileBytes: number; maxFiles: number; maxTotalBytes: number };
|
||||
runtimeAvailability: {
|
||||
availableForNewJobs: boolean;
|
||||
reason: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | null;
|
||||
};
|
||||
supportedRatios: readonly ProjectRatio[];
|
||||
}
|
||||
|
||||
export interface GenerationModelCatalog {
|
||||
readModel(modelId: string): GenerationModelSnapshot | undefined;
|
||||
}
|
||||
|
||||
export class StaticGenerationModelCatalog implements GenerationModelCatalog {
|
||||
private readonly models = new Map<string, GenerationModelSnapshot>();
|
||||
|
||||
constructor(models: GenerationModelSnapshot[]) {
|
||||
for (const model of models) this.models.set(model.modelId, structuredClone(model));
|
||||
}
|
||||
|
||||
readModel(modelId: string) {
|
||||
const model = this.models.get(modelId);
|
||||
return model ? structuredClone(model) : undefined;
|
||||
}
|
||||
|
||||
replace(model: GenerationModelSnapshot) {
|
||||
this.models.set(model.modelId, structuredClone(model));
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewGenerationReference {
|
||||
content: Readable;
|
||||
fileName: string;
|
||||
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||
projectedBytes: number;
|
||||
}
|
||||
|
||||
export interface GenerationSubmissionInput {
|
||||
clientSubmissionId: string;
|
||||
confirmedCreditCost: number;
|
||||
existingReferenceAssetIds: string[];
|
||||
idempotencyKey: string;
|
||||
mode: "new_project" | "existing_project";
|
||||
modelConfigVersion: number;
|
||||
modelId: string;
|
||||
newReferences: NewGenerationReference[];
|
||||
projectId?: string;
|
||||
prompt: string;
|
||||
ratio: ProjectRatio;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type GenerationSubmissionFields = Omit<GenerationSubmissionInput, "newReferences">;
|
||||
|
||||
export interface GenerationTaskView {
|
||||
confirmedCreditCost: number;
|
||||
createdAt: string;
|
||||
errorCategory: "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled"
|
||||
| "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid"
|
||||
| "unknown_retryable" | "unknown_non_retryable" | null;
|
||||
generationId: string;
|
||||
modelConfigVersion: number;
|
||||
modelId: string;
|
||||
projectId: string;
|
||||
prompt: string;
|
||||
ratio: ProjectRatio;
|
||||
referenceAssetIds: string[];
|
||||
referenceCount: number;
|
||||
reservedCredits: number;
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type GenerationSubmissionResult = { created: boolean; task: GenerationTaskView };
|
||||
|
||||
export interface GenerationUploadSession {
|
||||
abort(): void;
|
||||
commit(): Promise<GenerationSubmissionResult>;
|
||||
stageReference(reference: NewGenerationReference): Promise<void>;
|
||||
}
|
||||
|
||||
interface GenerationRow {
|
||||
confirmed_credit_cost: number;
|
||||
created_at: number;
|
||||
error_category: GenerationTaskView["errorCategory"];
|
||||
generation_id: string;
|
||||
model_config_version: number;
|
||||
model_id: string;
|
||||
owner_id: string;
|
||||
project_id: string;
|
||||
prompt: string;
|
||||
ratio: ProjectRatio;
|
||||
reserved_credits: number;
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function digest(value: string) {
|
||||
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function safeInteger(value: number) {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
export class GenerationSubmissionService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly beforeTransaction: (() => Promise<void>) | undefined;
|
||||
private readonly clock: () => number;
|
||||
private readonly credits: CreditService;
|
||||
private readonly models: GenerationModelCatalog;
|
||||
private readonly storage: ManagedStorage;
|
||||
|
||||
constructor(input: {
|
||||
beforeTransaction?: () => Promise<void>;
|
||||
clock?: () => number;
|
||||
credits: CreditService;
|
||||
models: GenerationModelCatalog;
|
||||
storage: ManagedStorage;
|
||||
}) {
|
||||
this.beforeTransaction = input.beforeTransaction;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.credits = input.credits;
|
||||
this.database = input.credits.database;
|
||||
this.models = input.models;
|
||||
this.storage = input.storage;
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
// The database connection is owned by CreditService.
|
||||
}
|
||||
|
||||
readCurrentTask(userId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT * FROM generation_jobs
|
||||
WHERE owner_id = ? AND status IN ('queued', 'running') AND submission_ready = 1
|
||||
ORDER BY created_at DESC, generation_id DESC LIMIT 1
|
||||
`).get(userId) as GenerationRow | undefined;
|
||||
return row ? this.taskView(row) : undefined;
|
||||
}
|
||||
|
||||
readTask(userId: string, generationId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT * FROM generation_jobs WHERE generation_id = ? AND owner_id = ? AND submission_ready = 1
|
||||
`).get(generationId, userId) as GenerationRow | undefined;
|
||||
if (!row) throw new GenerationSubmissionError("generation_not_found");
|
||||
return this.taskView(row);
|
||||
}
|
||||
|
||||
async submit(input: GenerationSubmissionInput): Promise<GenerationSubmissionResult> {
|
||||
const current = this.readCurrentTask(input.userId);
|
||||
if (current) return { created: false as const, task: current };
|
||||
const { newReferences, ...fields } = input;
|
||||
const upload = this.beginUpload(fields);
|
||||
try {
|
||||
for (const reference of newReferences) await upload.stageReference(reference);
|
||||
return await upload.commit();
|
||||
} catch (error) {
|
||||
upload.abort();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
beginUpload(input: GenerationSubmissionFields): GenerationUploadSession {
|
||||
const preflightModel = this.validate({ ...input, newReferences: [] });
|
||||
const staged: StagedManagedFile[] = [];
|
||||
let finished = false;
|
||||
const abort = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
for (const file of staged) this.storage.abandonStagedFile(file);
|
||||
};
|
||||
return {
|
||||
abort,
|
||||
commit: async () => {
|
||||
if (finished) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
if (this.beforeTransaction) await this.beforeTransaction();
|
||||
try {
|
||||
const result = this.immediate(() => this.commitSubmission({ ...input, newReferences: [] }, staged));
|
||||
if (result.created) finished = true;
|
||||
else abort();
|
||||
return result;
|
||||
} catch (error) {
|
||||
abort();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
stageReference: async (reference) => {
|
||||
if (finished) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
const nextCount = input.existingReferenceAssetIds.length + staged.length + 1;
|
||||
const nextBytes = staged.reduce((sum, file) => sum + file.bytes, 0) + reference.projectedBytes;
|
||||
if (nextCount > preflightModel.referenceLimits.maxFiles || nextBytes > preflightModel.referenceLimits.maxTotalBytes
|
||||
|| !safeInteger(reference.projectedBytes) || reference.projectedBytes > preflightModel.referenceLimits.maxFileBytes) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
try {
|
||||
staged.push(await this.storage.stagePrivateImage({
|
||||
content: reference.content,
|
||||
expectedMimeType: reference.mimeType,
|
||||
fileName: reference.fileName,
|
||||
maximumBytes: preflightModel.referenceLimits.maxFileBytes,
|
||||
operationId: randomUUID(),
|
||||
ownerRef: input.userId,
|
||||
projectedWriteBytes: reference.projectedBytes,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof GenerationSubmissionError || (error && typeof error === "object" && "code" in error)) throw error;
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private validate(input: GenerationSubmissionInput) {
|
||||
if (!uuidPattern.test(input.userId) || !uuidPattern.test(input.clientSubmissionId)
|
||||
|| !idempotencyPattern.test(input.idempotencyKey) || !projectRatios.includes(input.ratio)
|
||||
|| !safeInteger(input.modelConfigVersion) || !safeInteger(input.confirmedCreditCost)
|
||||
|| (input.mode === "existing_project" && (!input.projectId || !uuidPattern.test(input.projectId)))
|
||||
|| (input.mode === "new_project" && input.projectId !== undefined)) {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
const prompt = normalizePrompt(input.prompt);
|
||||
const model = this.models.readModel(input.modelId);
|
||||
if (!model) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" });
|
||||
const workerState = this.database.prepare("SELECT status FROM worker_runtime_state WHERE singleton = 1").get() as { status: "ready" | "degraded" } | undefined;
|
||||
if (workerState?.status === "degraded") throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" });
|
||||
if (model.configVersion !== input.modelConfigVersion || model.creditCost !== input.confirmedCreditCost) {
|
||||
throw new GenerationSubmissionError("model_config_stale", {
|
||||
latest: { configVersion: model.configVersion, creditCost: model.creditCost, modelId: model.modelId },
|
||||
});
|
||||
}
|
||||
if (!model.enabled) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" });
|
||||
if (model.contractValidationStatus !== "verified") {
|
||||
throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" });
|
||||
}
|
||||
if (!model.runtimeAvailability.availableForNewJobs) {
|
||||
throw new GenerationSubmissionError("generation_blocked", { errorCategory: model.runtimeAvailability.reason ?? "model_disabled" });
|
||||
}
|
||||
if (prompt.length > model.promptMaxLength || !model.supportedRatios.includes(input.ratio)) {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
const referenceCount = input.newReferences.length + input.existingReferenceAssetIds.length;
|
||||
const projectedBytes = input.newReferences.reduce((sum, reference) => sum + reference.projectedBytes, 0);
|
||||
if (referenceCount > model.referenceLimits.maxFiles || projectedBytes > model.referenceLimits.maxTotalBytes
|
||||
|| input.newReferences.some((reference) => !safeInteger(reference.projectedBytes) || reference.projectedBytes > model.referenceLimits.maxFileBytes)
|
||||
|| new Set(input.existingReferenceAssetIds).size !== input.existingReferenceAssetIds.length
|
||||
|| input.existingReferenceAssetIds.some((id) => !uuidPattern.test(id))
|
||||
|| (input.mode === "new_project" && input.existingReferenceAssetIds.length > 0)) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
const storageState = this.storage.getState();
|
||||
if (storageState.storage_status !== "active") {
|
||||
throw new GenerationSubmissionError("generation_storage_unavailable", {
|
||||
storage: {
|
||||
capacityStatus: storageState.storage_status,
|
||||
remainingBytes: Math.max(
|
||||
0,
|
||||
storageState.hard_limit_bytes - storageState.managed_content_bytes - storageState.active_storage_reservations_bytes,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
private commitSubmission(input: GenerationSubmissionInput, staged: StagedManagedFile[]) {
|
||||
const model = this.validate(input);
|
||||
const requestHash = digest(stableJson({
|
||||
client_submission_id: input.clientSubmissionId,
|
||||
confirmed_credit_cost: input.confirmedCreditCost,
|
||||
existing_reference_asset_ids: input.existingReferenceAssetIds,
|
||||
mode: input.mode,
|
||||
model_config_version: input.modelConfigVersion,
|
||||
model_id: input.modelId,
|
||||
new_references: staged.map((file) => ({ bytes: file.bytes, mime_type: file.mimeType, sha256: file.sha256 })),
|
||||
project_id: input.projectId ?? null,
|
||||
prompt: normalizePrompt(input.prompt),
|
||||
ratio: input.ratio,
|
||||
}));
|
||||
const keyDigest = digest(input.idempotencyKey);
|
||||
const receipt = this.database.prepare(`
|
||||
SELECT r.request_hash, g.* FROM generation_submission_receipts r
|
||||
JOIN generation_jobs g ON g.generation_id = r.generation_id
|
||||
WHERE r.owner_id = ? AND r.idempotency_key_digest = ?
|
||||
`).get(input.userId, keyDigest) as (GenerationRow & { request_hash: string }) | undefined;
|
||||
if (receipt) {
|
||||
if (receipt.request_hash !== requestHash) throw new GenerationSubmissionError("generation_idempotency_conflict");
|
||||
return { created: false as const, task: this.taskView(receipt) };
|
||||
}
|
||||
const bySubmission = this.database.prepare("SELECT * FROM generation_jobs WHERE client_submission_id = ?")
|
||||
.get(input.clientSubmissionId) as (GenerationRow & { submission_request_hash: string | null }) | undefined;
|
||||
if (bySubmission) {
|
||||
if (bySubmission.submission_request_hash !== requestHash || bySubmission.owner_id !== input.userId) {
|
||||
throw new GenerationSubmissionError("generation_idempotency_conflict");
|
||||
}
|
||||
return { created: false as const, task: this.taskView(bySubmission) };
|
||||
}
|
||||
const current = this.readCurrentTask(input.userId);
|
||||
if (current) return { created: false as const, task: current };
|
||||
|
||||
const now = this.clock();
|
||||
const prompt = normalizePrompt(input.prompt);
|
||||
const generationId = randomUUID();
|
||||
const projectId = input.mode === "new_project"
|
||||
? this.insertProject(input.userId, prompt, input.ratio, now)
|
||||
: this.validateExistingProject(input.userId, input.projectId!, input.ratio, prompt, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO generation_jobs (
|
||||
generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version,
|
||||
confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at,
|
||||
client_submission_id, submission_request_hash, submission_ready, config_snapshot_json
|
||||
) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, 0, NULL, NULL, ?, ?, ?, ?, 0, ?)
|
||||
`).run(
|
||||
generationId, input.userId, projectId, prompt, input.ratio, input.modelId, input.modelConfigVersion,
|
||||
input.confirmedCreditCost, now, now, input.clientSubmissionId, requestHash,
|
||||
stableJson({
|
||||
config_set_version: model.configSetVersion,
|
||||
config_version: model.configVersion,
|
||||
credit_cost: model.creditCost,
|
||||
model_id: model.modelId,
|
||||
prompt_max_length: model.promptMaxLength,
|
||||
reference_limits: model.referenceLimits,
|
||||
supported_ratios: model.supportedRatios,
|
||||
}),
|
||||
);
|
||||
|
||||
const referenceIds: string[] = [];
|
||||
for (const file of staged) {
|
||||
this.storage.moveStagedFile(file);
|
||||
this.database.prepare(`
|
||||
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||
VALUES (?, 'reference', ?, ?, ?, ?, ?, 'committed', ?)
|
||||
`).run(file.fileId, input.userId, file.relativePath, file.bytes, file.mimeType, file.sha256, iso(now));
|
||||
this.database.prepare(`INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'reference', ?)`)
|
||||
.run(projectId, file.fileId, now);
|
||||
this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)`)
|
||||
.run(`project:${projectId}:${file.fileId}`, file.fileId, iso(now));
|
||||
referenceIds.push(file.fileId);
|
||||
}
|
||||
for (const referenceId of input.existingReferenceAssetIds) {
|
||||
const allowed = this.database.prepare(`
|
||||
SELECT mf.file_id FROM managed_files mf
|
||||
JOIN project_resource_files prf ON prf.managed_file_id = mf.file_id
|
||||
JOIN projects p ON p.project_id = prf.project_id
|
||||
WHERE mf.file_id = ? AND mf.file_kind = 'reference' AND mf.status = 'committed'
|
||||
AND mf.owner_ref = ? AND prf.project_id = ? AND p.owner_id = ? AND p.status = 'active'
|
||||
`).get(referenceId, input.userId, projectId, input.userId);
|
||||
if (!allowed) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
referenceIds.push(referenceId);
|
||||
}
|
||||
referenceIds.forEach((referenceId, index) => this.database.prepare(`
|
||||
INSERT INTO generation_reference_snapshots (generation_id, position, managed_file_id, source_kind, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(generationId, index, referenceId, index < staged.length ? "uploaded" : "existing", now));
|
||||
|
||||
this.credits.reserveGeneration({
|
||||
creditCost: input.confirmedCreditCost,
|
||||
generationId,
|
||||
modelId: input.modelId,
|
||||
operationKey: `generation:${generationId}:reserve`,
|
||||
userId: input.userId,
|
||||
});
|
||||
this.database.prepare("UPDATE generation_jobs SET submission_ready = 1 WHERE generation_id = ?").run(generationId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO generation_submission_receipts (owner_id, idempotency_key_digest, request_hash, generation_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(input.userId, keyDigest, requestHash, generationId, now);
|
||||
this.consumeStagedStorage(staged, now);
|
||||
return { created: true as const, task: this.readTask(input.userId, generationId) };
|
||||
}
|
||||
|
||||
private insertProject(ownerId: string, prompt: string, ratio: ProjectRatio, now: number) {
|
||||
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||
.get(ownerId) as { count: number };
|
||||
if (active.count >= projectLimit) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
const projectId = randomUUID();
|
||||
const pixels = ratioPixels[ratio];
|
||||
const name = defaultProjectName(prompt, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO projects (
|
||||
project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height,
|
||||
status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL)
|
||||
`).run(projectId, ownerId, name, prompt, ratio, pixels.width, pixels.height, now, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, 1, ?, ?, ?)
|
||||
`).run(projectId, name, stableJson(defaultCanvasState(ratio, pixels, null)), now);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
private validateExistingProject(ownerId: string, projectId: string, ratio: ProjectRatio, prompt: string, now: number) {
|
||||
const project = this.database.prepare("SELECT ratio FROM projects WHERE project_id = ? AND owner_id = ? AND status = 'active'")
|
||||
.get(projectId, ownerId) as { ratio: ProjectRatio } | undefined;
|
||||
if (!project || project.ratio !== ratio) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
const history = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?").get(projectId) as { count: number };
|
||||
if (history.count >= historyLimit) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?").run(prompt, now, projectId);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
private consumeStagedStorage(staged: StagedManagedFile[], now: number) {
|
||||
if (staged.length === 0) return;
|
||||
const total = staged.reduce((sum, file) => sum + file.bytes, 0);
|
||||
for (const file of staged) {
|
||||
this.database.prepare(`
|
||||
UPDATE storage_reservations SET status = 'consumed', resolved_at = ?
|
||||
WHERE operation_id = ? AND status = 'active'
|
||||
`).run(iso(now), file.operationId);
|
||||
}
|
||||
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1")
|
||||
.get() as { managed_content_bytes: number };
|
||||
const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'")
|
||||
.get() as { bytes: number };
|
||||
const nextBytes = state.managed_content_bytes + total;
|
||||
const classification = classifyCapacity(nextBytes, active.bytes);
|
||||
this.database.prepare(`
|
||||
UPDATE local_backend_storage_state
|
||||
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||
WHERE singleton = 1
|
||||
`).run(nextBytes, classification.capacity_notice_level, classification.storage_status, iso(now));
|
||||
}
|
||||
|
||||
private taskView(row: GenerationRow): GenerationTaskView {
|
||||
const referenceAssetIds = (this.database.prepare(`
|
||||
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||
`).all(row.generation_id) as Array<{ managed_file_id: string }>).map((entry) => entry.managed_file_id);
|
||||
return {
|
||||
confirmedCreditCost: row.confirmed_credit_cost,
|
||||
createdAt: iso(row.created_at),
|
||||
errorCategory: row.error_category,
|
||||
generationId: row.generation_id,
|
||||
modelConfigVersion: row.model_config_version,
|
||||
modelId: row.model_id,
|
||||
projectId: row.project_id,
|
||||
prompt: row.prompt,
|
||||
ratio: row.ratio,
|
||||
referenceAssetIds,
|
||||
referenceCount: referenceAssetIds.length,
|
||||
reservedCredits: row.reserved_credits,
|
||||
status: row.status,
|
||||
updatedAt: iso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private immediate<T>(action: () => T): T {
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
this.database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureColumn(table: string, column: string, definition: string) {
|
||||
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.ensureColumn("generation_jobs", "client_submission_id", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "submission_request_hash", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "submission_ready", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.ensureColumn("generation_jobs", "config_snapshot_json", "TEXT");
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS worker_runtime_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
status TEXT NOT NULL CHECK (status IN ('ready', 'degraded')),
|
||||
reason TEXT,
|
||||
owner_id TEXT,
|
||||
lock_expires_at INTEGER,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_active_owner
|
||||
ON generation_jobs(owner_id) WHERE status IN ('queued', 'running');
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_client_submission
|
||||
ON generation_jobs(client_submission_id) WHERE client_submission_id IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS generation_submission_receipts (
|
||||
owner_id TEXT NOT NULL,
|
||||
idempotency_key_digest TEXT NOT NULL CHECK (length(idempotency_key_digest) = 64),
|
||||
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||
generation_id TEXT NOT NULL UNIQUE REFERENCES generation_jobs(generation_id),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, idempotency_key_digest)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation_reference_snapshots (
|
||||
generation_id TEXT NOT NULL REFERENCES generation_jobs(generation_id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
managed_file_id TEXT NOT NULL REFERENCES managed_files(file_id),
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('uploaded', 'existing')),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (generation_id, position),
|
||||
UNIQUE (generation_id, managed_file_id)
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS generation_reference_snapshots_no_update
|
||||
BEFORE UPDATE ON generation_reference_snapshots BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END;
|
||||
DROP TRIGGER IF EXISTS generation_reference_snapshots_no_delete;
|
||||
CREATE TRIGGER generation_reference_snapshots_no_delete
|
||||
BEFORE DELETE ON generation_reference_snapshots
|
||||
WHEN dada_allow_privacy_purge() <> 1
|
||||
BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END;
|
||||
`);
|
||||
this.ensureColumn("worker_runtime_state", "owner_id", "TEXT");
|
||||
this.ensureColumn("worker_runtime_state", "lock_expires_at", "INTEGER");
|
||||
this.database.prepare("INSERT OR IGNORE INTO worker_runtime_state (singleton, status, reason, owner_id, lock_expires_at, updated_at) VALUES (1, 'ready', NULL, NULL, NULL, ?)").run(this.clock());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class LatestExportError extends Error {
|
||||
constructor(readonly code: "invalid" | "not_found" | "conflict") {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { ManagedStorage } from "./managed-storage.js";
|
||||
import { stableJson } from "./projects.js";
|
||||
import { LatestExportError } from "./latest-export-errors.js";
|
||||
export { LatestExportError } from "./latest-export-errors.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
export type ExportFormat = "jpg" | "png";
|
||||
|
||||
interface ExportRow {
|
||||
byte_size: number;
|
||||
created_at: number;
|
||||
export_id: string;
|
||||
format: ExportFormat;
|
||||
managed_file_id: string;
|
||||
pixel_height: number;
|
||||
pixel_width: number;
|
||||
project_id: string;
|
||||
sha256: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
interface ManagedFileRow {
|
||||
byte_size: number;
|
||||
file_id: string;
|
||||
mime_type: string;
|
||||
relative_path: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
type LatestExportView = ReturnType<LatestExportService["view"]>;
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function validSha256(value: string) {
|
||||
return /^[0-9a-f]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export class LatestExportService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
private readonly storage: ManagedStorage;
|
||||
|
||||
constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.storage = input.storage;
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
async saveLatest(input: {
|
||||
byteSize: number;
|
||||
content: Readable;
|
||||
exportId: string;
|
||||
format: ExportFormat;
|
||||
ownerId: string;
|
||||
pixelHeight: number;
|
||||
pixelWidth: number;
|
||||
projectId: string;
|
||||
sha256: string;
|
||||
stateVersion: number;
|
||||
}) {
|
||||
this.validateInput(input);
|
||||
const requestHash = createHash("sha256").update(stableJson({
|
||||
byte_size: input.byteSize, export_id: input.exportId, format: input.format,
|
||||
owner_id: input.ownerId,
|
||||
pixel_height: input.pixelHeight, pixel_width: input.pixelWidth, project_id: input.projectId,
|
||||
sha256: input.sha256, state_version: input.stateVersion,
|
||||
})).digest("hex");
|
||||
const replay = this.database.prepare("SELECT owner_id, request_hash, response_json FROM latest_export_receipts WHERE export_id = ?")
|
||||
.get(input.exportId) as { owner_id: string; request_hash: string; response_json: string | null } | undefined;
|
||||
if (replay) {
|
||||
if (replay.owner_id !== input.ownerId || replay.request_hash !== requestHash) throw new LatestExportError("conflict");
|
||||
const replayHash = createHash("sha256");
|
||||
let replayBytes = 0;
|
||||
for await (const chunk of input.content) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
replayBytes += bytes.byteLength;
|
||||
replayHash.update(bytes);
|
||||
}
|
||||
if (replayBytes !== input.byteSize || replayHash.digest("hex") !== input.sha256) throw new LatestExportError("conflict");
|
||||
return replay.response_json ? JSON.parse(replay.response_json) as LatestExportView : this.readByExportId(input.ownerId, input.exportId);
|
||||
}
|
||||
this.assertWritableProject(input);
|
||||
const stored = await this.storage.commitStream({
|
||||
content: input.content,
|
||||
expectedMimeType: input.format === "png" ? "image/png" : "image/jpeg",
|
||||
expectedSha256: input.sha256,
|
||||
fileKind: "export",
|
||||
fileName: `latest.${input.format === "jpg" ? "jpg" : "png"}`,
|
||||
operationId: input.exportId,
|
||||
ownerRef: input.ownerId,
|
||||
projectedWriteBytes: input.byteSize,
|
||||
});
|
||||
try {
|
||||
if (stored.bytes !== input.byteSize) throw new LatestExportError("invalid");
|
||||
const createdAt = this.clock();
|
||||
const response = this.view({
|
||||
byte_size: stored.bytes,
|
||||
created_at: createdAt,
|
||||
export_id: input.exportId,
|
||||
format: input.format,
|
||||
managed_file_id: stored.file_id,
|
||||
pixel_height: input.pixelHeight,
|
||||
pixel_width: input.pixelWidth,
|
||||
project_id: input.projectId,
|
||||
sha256: stored.sha256,
|
||||
state_version: input.stateVersion,
|
||||
});
|
||||
const transaction = this.database.transaction(() => {
|
||||
this.assertWritableProject(input);
|
||||
const previous = this.database.prepare("SELECT managed_file_id FROM latest_exports WHERE project_id = ? AND format = ?")
|
||||
.get(input.projectId, input.format) as { managed_file_id: string } | undefined;
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at)
|
||||
VALUES (?, ?, 'export', ?)
|
||||
`).run(input.projectId, stored.file_id, createdAt);
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
|
||||
VALUES (?, ?, 'project', ?)
|
||||
`).run(`project:${input.projectId}:${stored.file_id}`, stored.file_id, iso(createdAt));
|
||||
this.database.prepare(`
|
||||
INSERT INTO latest_exports (
|
||||
project_id, format, export_id, managed_file_id, state_version, sha256,
|
||||
byte_size, pixel_width, pixel_height, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, format) DO UPDATE SET
|
||||
export_id = excluded.export_id, managed_file_id = excluded.managed_file_id,
|
||||
state_version = excluded.state_version, sha256 = excluded.sha256,
|
||||
byte_size = excluded.byte_size, pixel_width = excluded.pixel_width,
|
||||
pixel_height = excluded.pixel_height, created_at = excluded.created_at
|
||||
`).run(
|
||||
input.projectId, input.format, input.exportId, stored.file_id, input.stateVersion,
|
||||
stored.sha256, stored.bytes, input.pixelWidth, input.pixelHeight, createdAt,
|
||||
);
|
||||
this.database.prepare("INSERT INTO latest_export_receipts (export_id, owner_id, request_hash, response_json, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(input.exportId, input.ownerId, requestHash, stableJson(response), createdAt);
|
||||
if (previous && previous.managed_file_id !== stored.file_id) this.retireReplacedFile(previous.managed_file_id, createdAt);
|
||||
});
|
||||
transaction.immediate();
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.storage.retireManagedFile(stored.file_id, "compensation");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getLatest(ownerId: string, projectId: string, format: ExportFormat) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT le.*, mf.mime_type, mf.relative_path
|
||||
FROM latest_exports le
|
||||
JOIN projects p ON p.project_id = le.project_id
|
||||
JOIN managed_files mf ON mf.file_id = le.managed_file_id AND mf.status = 'committed'
|
||||
WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND le.format = ?
|
||||
`).get(ownerId, projectId, format) as (ExportRow & ManagedFileRow) | undefined;
|
||||
if (!row) throw new LatestExportError("not_found");
|
||||
const path = this.storage.resolveManagedFile(row.managed_file_id);
|
||||
if (!path) throw new LatestExportError("not_found");
|
||||
return { ...this.view(row), mimeType: row.mime_type, path };
|
||||
}
|
||||
|
||||
getOriginal(ownerId: string, projectId: string, imageId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT mf.file_id, mf.relative_path, mf.byte_size, mf.mime_type, mf.sha256
|
||||
FROM project_images pi
|
||||
JOIN projects p ON p.project_id = pi.project_id
|
||||
JOIN managed_files mf ON mf.file_id = pi.image_id AND mf.status = 'committed'
|
||||
WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND pi.image_id = ?
|
||||
`).get(ownerId, projectId, imageId) as ManagedFileRow | undefined;
|
||||
if (!row) throw new LatestExportError("not_found");
|
||||
const path = this.storage.resolveManagedFile(row.file_id);
|
||||
if (!path) throw new LatestExportError("not_found");
|
||||
return { ...row, path };
|
||||
}
|
||||
|
||||
private assertWritableProject(input: Pick<Parameters<LatestExportService["saveLatest"]>[0], "ownerId" | "pixelHeight" | "pixelWidth" | "projectId" | "stateVersion">) {
|
||||
const project = this.database.prepare(`
|
||||
SELECT state_version, pixel_width, pixel_height FROM projects
|
||||
WHERE owner_id = ? AND project_id = ? AND status = 'active'
|
||||
`).get(input.ownerId, input.projectId) as { pixel_height: number; pixel_width: number; state_version: number } | undefined;
|
||||
if (!project) throw new LatestExportError("not_found");
|
||||
if (project.state_version !== input.stateVersion) throw new LatestExportError("conflict");
|
||||
if (project.pixel_width !== input.pixelWidth || project.pixel_height !== input.pixelHeight) throw new LatestExportError("invalid");
|
||||
}
|
||||
|
||||
private readByExportId(ownerId: string, exportId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT le.* FROM latest_exports le JOIN projects p ON p.project_id = le.project_id
|
||||
WHERE p.owner_id = ? AND le.export_id = ?
|
||||
`).get(ownerId, exportId) as ExportRow | undefined;
|
||||
if (!row) throw new LatestExportError("not_found");
|
||||
return this.view(row);
|
||||
}
|
||||
|
||||
private retireReplacedFile(fileId: string, timestamp: number) {
|
||||
const file = this.database.prepare("SELECT relative_path, byte_size FROM managed_files WHERE file_id = ? AND status = 'committed'")
|
||||
.get(fileId) as { byte_size: number; relative_path: string } | undefined;
|
||||
if (!file) return;
|
||||
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
||||
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(iso(timestamp), fileId);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||
reason, status, created_at, completed_at, last_error
|
||||
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
|
||||
`).run(randomUUID(), fileId, file.relative_path, file.byte_size, iso(timestamp));
|
||||
}
|
||||
|
||||
private validateInput(input: Parameters<LatestExportService["saveLatest"]>[0]) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.exportId) || !["jpg", "png"].includes(input.format)
|
||||
|| !validSha256(input.sha256) || ![input.byteSize, input.pixelHeight, input.pixelWidth, input.stateVersion].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
||||
throw new LatestExportError("invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private view(row: ExportRow) {
|
||||
return {
|
||||
byteSize: row.byte_size,
|
||||
createdAt: iso(row.created_at),
|
||||
downloadUrl: `/api/v1/projects/${row.project_id}/latest-exports/${row.format}`,
|
||||
exportId: row.export_id,
|
||||
format: row.format,
|
||||
pixelHeight: row.pixel_height,
|
||||
pixelWidth: row.pixel_width,
|
||||
projectId: row.project_id,
|
||||
sha256: row.sha256,
|
||||
stateVersion: row.state_version,
|
||||
};
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS latest_export_receipts (
|
||||
export_id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||
response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
const columns = this.database.prepare("PRAGMA table_info(latest_export_receipts)").all() as Array<{ name: string }>;
|
||||
if (!columns.some((column) => column.name === "response_json")) {
|
||||
this.database.exec("ALTER TABLE latest_export_receipts ADD COLUMN response_json TEXT");
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
-6
@@ -1,19 +1,85 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { registrationNotice } from "@dada/shared-contracts";
|
||||
|
||||
import { createApp } from "./app.js";
|
||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||
import { readConfiguredLocalDataRoot } from "./local-data-root.js";
|
||||
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
||||
import { ManagedStorage } from "./managed-storage.js";
|
||||
import { LatestExportService } from "./latest-exports.js";
|
||||
import { CreditService } from "./credits.js";
|
||||
import { ProjectService } from "./projects.js";
|
||||
import { RegistrationService } from "./registration.js";
|
||||
import { RecentAssetService } from "./recent-assets.js";
|
||||
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 } from "./model-configuration.js";
|
||||
import { MockAmapAdapter } from "./amap-adapter.js";
|
||||
|
||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||
let registration: RegistrationService | undefined;
|
||||
let projects: ProjectService | undefined;
|
||||
let credits: CreditService | undefined;
|
||||
let storage: ManagedStorage | undefined;
|
||||
let latestExports: LatestExportService | undefined;
|
||||
let models: ModelConfigurationService | undefined;
|
||||
let recentAssets: RecentAssetService | undefined;
|
||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||
if (credentialChannelEnabled) {
|
||||
initializeApiCredentialClients(await receiveApiCredentials());
|
||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||
try {
|
||||
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
||||
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||
.digest();
|
||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||
registration = new RegistrationService({
|
||||
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||
challengePepper: derivePepper("challenge-pepper"),
|
||||
currentPrivacyNoticeVersion: registrationNotice.version,
|
||||
databasePath,
|
||||
invitePepper: derivePepper("invite-pepper"),
|
||||
resend: new MockResendAdapter(),
|
||||
sessionPepper: derivePepper("session-pepper"),
|
||||
});
|
||||
projects = new ProjectService({ databasePath });
|
||||
credits = new CreditService({ databasePath });
|
||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
latestExports = new LatestExportService({ databasePath, storage });
|
||||
models = new ModelConfigurationService({ database: registration.database });
|
||||
recentAssets = new RecentAssetService({ database: registration.database });
|
||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||
} catch (error) {
|
||||
latestExports?.close();
|
||||
latestExports = undefined;
|
||||
storage?.close();
|
||||
storage = undefined;
|
||||
credits?.close();
|
||||
credits = undefined;
|
||||
projects?.close();
|
||||
projects = undefined;
|
||||
registration?.close();
|
||||
registration = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
clients.adminAllowlistPepper.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
||||
const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {});
|
||||
const app = await createApp({
|
||||
amap: new MockAmapAdapter(),
|
||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||
...(credits ? { credits } : {}),
|
||||
...(latestExports ? { latestExports } : {}),
|
||||
...(models ? { models } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
...(registration ? { registration } : {}),
|
||||
...(recentAssets ? { recentAssets } : {}),
|
||||
});
|
||||
|
||||
await app.listen({
|
||||
host: "127.0.0.1",
|
||||
@@ -24,14 +90,17 @@ const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
||||
if (controlPipeIndex >= 0) {
|
||||
const controlPipe = process.argv[controlPipeIndex + 1];
|
||||
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||
let storage: ManagedStorage | undefined;
|
||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||
await app.close();
|
||||
latestExports?.close();
|
||||
credits?.close();
|
||||
projects?.close();
|
||||
registration?.close();
|
||||
storage?.close();
|
||||
});
|
||||
try {
|
||||
const dataRoot = readConfiguredLocalDataRoot();
|
||||
storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||
if (!storage) storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
||||
const logger = new StructuredJsonlLogger({
|
||||
component: "api",
|
||||
directory: join(dataRoot, "logs", "api"),
|
||||
|
||||
+183
-15
@@ -15,6 +15,13 @@ import { pipeline } from "node:stream/promises";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import {
|
||||
auditRetentionMilliseconds,
|
||||
ensureAdminOperationAuditSchema,
|
||||
isSafeAuditRef,
|
||||
isSafeAuditSummaryJson,
|
||||
serializeAuditSummary,
|
||||
} from "./audit-policy.js";
|
||||
import { resolvePathWithinRoot } from "./local-data-root.js";
|
||||
import {
|
||||
HARD_LIMIT_BYTES,
|
||||
@@ -93,6 +100,10 @@ function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function auditExpiry(occurredAt: number) {
|
||||
return occurredAt + auditRetentionMilliseconds;
|
||||
}
|
||||
|
||||
function validatePositiveBytes(value: number, name: string) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`);
|
||||
}
|
||||
@@ -102,6 +113,9 @@ function sniffMime(prefix: Buffer) {
|
||||
return "image/png";
|
||||
}
|
||||
if (prefix.length >= 3 && prefix[0] === 0xff && prefix[1] === 0xd8 && prefix[2] === 0xff) return "image/jpeg";
|
||||
if (prefix.length >= 12 && prefix.subarray(0, 4).toString("ascii") === "RIFF" && prefix.subarray(8, 12).toString("ascii") === "WEBP") {
|
||||
return "image/webp";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
@@ -118,7 +132,7 @@ function listFiles(root: string): string[] {
|
||||
|
||||
export interface CommitStreamInput {
|
||||
content: Readable;
|
||||
expectedMimeType: "image/png" | "image/jpeg" | "application/octet-stream";
|
||||
expectedMimeType: "image/png" | "image/jpeg" | "image/webp" | "application/octet-stream";
|
||||
expectedSha256?: string;
|
||||
failurePoint?: CommitFailurePoint;
|
||||
fileKind: ManagedFileKind;
|
||||
@@ -128,6 +142,20 @@ export interface CommitStreamInput {
|
||||
projectedWriteBytes: number;
|
||||
}
|
||||
|
||||
export interface StagedManagedFile {
|
||||
bytes: number;
|
||||
destinationPath: string;
|
||||
fileId: string;
|
||||
fileKind: ManagedFileKind;
|
||||
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
||||
operationId: string;
|
||||
ownerRef: string;
|
||||
relativePath: string;
|
||||
sha256: string;
|
||||
stagingDirectory: string;
|
||||
stagingPath: string;
|
||||
}
|
||||
|
||||
export class ManagedStorage {
|
||||
readonly dataRoot: string;
|
||||
readonly databasePath: string;
|
||||
@@ -151,6 +179,12 @@ export class ManagedStorage {
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
|
||||
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
|
||||
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||
this.migrate();
|
||||
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1").get() as { managed_content_bytes: number };
|
||||
this.measurementBaselineBytes = Math.max(0, state.managed_content_bytes - this.physicalManagedBytes());
|
||||
@@ -180,6 +214,7 @@ export class ManagedStorage {
|
||||
CREATE TABLE IF NOT EXISTS managed_files (
|
||||
file_id TEXT PRIMARY KEY,
|
||||
file_kind TEXT NOT NULL CHECK (file_kind IN ('reference', 'generated', 'export', 'derived', 'sticker_original', 'sticker_thumbnail')),
|
||||
owner_ref TEXT,
|
||||
relative_path TEXT NOT NULL UNIQUE,
|
||||
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
||||
mime_type TEXT NOT NULL,
|
||||
@@ -222,12 +257,27 @@ export class ManagedStorage {
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||
log_id TEXT PRIMARY KEY,
|
||||
operation TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||
actor_ref TEXT NOT NULL,
|
||||
operation_type TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||
before_summary TEXT,
|
||||
after_summary TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
`);
|
||||
const managedFileColumns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>;
|
||||
if (!managedFileColumns.some((column) => column.name === "owner_ref")) {
|
||||
this.database.exec("ALTER TABLE managed_files ADD COLUMN owner_ref TEXT");
|
||||
}
|
||||
ensureAdminOperationAuditSchema(this.database, Date.now());
|
||||
const initial = classifyCapacity(0, 0);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO local_backend_storage_state
|
||||
@@ -416,9 +466,9 @@ export class ManagedStorage {
|
||||
|
||||
const commit = this.database.transaction(() => {
|
||||
this.database.prepare(`
|
||||
INSERT INTO managed_files (file_id, file_kind, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'committed', ?)
|
||||
`).run(fileId, input.fileKind, destination.relativePath, byteSize, input.expectedMimeType, sha256, now());
|
||||
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'committed', ?)
|
||||
`).run(fileId, input.fileKind, input.ownerRef, destination.relativePath, byteSize, input.expectedMimeType, sha256, now());
|
||||
this.database.prepare(`
|
||||
UPDATE local_backend_storage_state SET managed_content_bytes = managed_content_bytes + ? WHERE singleton = 1
|
||||
`).run(byteSize);
|
||||
@@ -446,6 +496,87 @@ export class ManagedStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async stagePrivateImage(input: {
|
||||
content: Readable;
|
||||
expectedMimeType: "image/png" | "image/jpeg" | "image/webp";
|
||||
fileName: string;
|
||||
maximumBytes: number;
|
||||
operationId: string;
|
||||
ownerRef: string;
|
||||
projectedWriteBytes: number;
|
||||
}): Promise<StagedManagedFile> {
|
||||
const fileId = randomUUID();
|
||||
const destination = this.destination({
|
||||
content: input.content,
|
||||
expectedMimeType: input.expectedMimeType,
|
||||
fileKind: "reference",
|
||||
fileName: input.fileName,
|
||||
operationId: input.operationId,
|
||||
ownerRef: input.ownerRef,
|
||||
projectedWriteBytes: input.projectedWriteBytes,
|
||||
}, fileId);
|
||||
if (!Number.isSafeInteger(input.maximumBytes) || input.maximumBytes <= 0) throw new Error("maximum_bytes_invalid");
|
||||
this.reserve(input.operationId, input.projectedWriteBytes);
|
||||
const stagingDirectory = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}`);
|
||||
const stagingPath = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}/payload.tmp`);
|
||||
try {
|
||||
mkdirSync(stagingDirectory, { recursive: true });
|
||||
const hash = createHash("sha256");
|
||||
let byteSize = 0;
|
||||
let prefix = Buffer.alloc(0);
|
||||
const inspect = new Transform({
|
||||
transform(chunk: Buffer | string, encoding, callback) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
||||
byteSize += bytes.byteLength;
|
||||
if (byteSize > input.maximumBytes) return callback(new Error("content_size_invalid"));
|
||||
hash.update(bytes);
|
||||
if (prefix.byteLength < 16) prefix = Buffer.concat([prefix, bytes.subarray(0, 16 - prefix.byteLength)]);
|
||||
callback(null, bytes);
|
||||
},
|
||||
});
|
||||
await pipeline(input.content, inspect, createWriteStream(stagingPath, { flags: "wx" }));
|
||||
validatePositiveBytes(byteSize, "actual_write_bytes");
|
||||
if (sniffMime(prefix) !== input.expectedMimeType) throw new Error("content_mime_invalid");
|
||||
const state = this.getState();
|
||||
const otherReservations = this.activeReservationBytes(input.operationId);
|
||||
if (state.managed_content_bytes + otherReservations + byteSize > HARD_LIMIT_BYTES) {
|
||||
throw new StorageCapacityError({ activeReservationBytes: otherReservations, managedContentBytes: state.managed_content_bytes, projectedWriteBytes: byteSize });
|
||||
}
|
||||
this.database.prepare("UPDATE storage_reservations SET projected_bytes = ? WHERE operation_id = ? AND status = 'active'")
|
||||
.run(byteSize, input.operationId);
|
||||
this.refreshState();
|
||||
return {
|
||||
bytes: byteSize,
|
||||
destinationPath: destination.absolutePath,
|
||||
fileId,
|
||||
fileKind: "reference",
|
||||
mimeType: input.expectedMimeType,
|
||||
operationId: input.operationId,
|
||||
ownerRef: input.ownerRef,
|
||||
relativePath: destination.relativePath,
|
||||
sha256: hash.digest("hex"),
|
||||
stagingDirectory,
|
||||
stagingPath,
|
||||
};
|
||||
} catch (error) {
|
||||
rmSync(stagingDirectory, { force: true, recursive: true });
|
||||
this.releaseReservation(input.operationId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
moveStagedFile(file: StagedManagedFile) {
|
||||
mkdirSync(dirname(file.destinationPath), { recursive: true });
|
||||
renameSync(file.stagingPath, file.destinationPath);
|
||||
rmSync(file.stagingDirectory, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
abandonStagedFile(file: StagedManagedFile) {
|
||||
if (existsSync(file.destinationPath)) this.queueCompensation(file.relativePath, statSync(file.destinationPath).size);
|
||||
else rmSync(file.stagingDirectory, { force: true, recursive: true });
|
||||
this.releaseReservation(file.operationId);
|
||||
}
|
||||
|
||||
async commitBufferFixture(fileKind: ManagedFileKind, fileName: string, bytes: Buffer) {
|
||||
return this.commitStream({
|
||||
content: Readable.from(bytes),
|
||||
@@ -514,6 +645,26 @@ export class ManagedStorage {
|
||||
return row ? resolvePathWithinRoot(this.dataRoot, row.relative_path) : undefined;
|
||||
}
|
||||
|
||||
retireManagedFile(fileId: string, reason: "compensation" | "purge" = "compensation") {
|
||||
const transaction = this.database.transaction(() => {
|
||||
const file = this.database.prepare(`
|
||||
SELECT file_id, relative_path, byte_size FROM managed_files
|
||||
WHERE file_id = ? AND status = 'committed'
|
||||
`).get(fileId) as { byte_size: number; file_id: string; relative_path: string } | undefined;
|
||||
if (!file) return;
|
||||
const retiredAt = now();
|
||||
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
||||
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(retiredAt, fileId);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||
reason, status, created_at, completed_at, last_error
|
||||
) VALUES (?, ?, ?, ?, 1, ?, 'pending', ?, NULL, NULL)
|
||||
`).run(randomUUID(), fileId, file.relative_path, file.byte_size, reason, retiredAt);
|
||||
});
|
||||
transaction.immediate();
|
||||
}
|
||||
|
||||
addAssetReference(fileId: string, referenceType: "project" | "release") {
|
||||
if (this.inspectAction("project_json_write") !== "allow") throw new StorageUnavailableError();
|
||||
this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, ?, ?)`)
|
||||
@@ -561,9 +712,15 @@ export class ManagedStorage {
|
||||
return row.count > 0;
|
||||
});
|
||||
if (conflict) {
|
||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
|
||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'denied_reference_conflict', ?, ?)")
|
||||
.run(randomUUID(), requestId, now());
|
||||
const confirmedAt = now();
|
||||
const occurredAt = Date.now();
|
||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'failed', NULL, ?, ?, ?)
|
||||
`).run(randomUUID(), requestId, serializeAuditSummary({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt));
|
||||
return false;
|
||||
}
|
||||
for (const file of files) {
|
||||
@@ -574,9 +731,15 @@ export class ManagedStorage {
|
||||
VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
|
||||
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now());
|
||||
}
|
||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
|
||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'queued', ?, ?)")
|
||||
.run(randomUUID(), requestId, now());
|
||||
const confirmedAt = now();
|
||||
const occurredAt = Date.now();
|
||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'succeeded', NULL, ?, ?, ?)
|
||||
`).run(randomUUID(), requestId, serializeAuditSummary({ status: "queued" }), occurredAt, auditExpiry(occurredAt));
|
||||
return true;
|
||||
});
|
||||
if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
|
||||
@@ -605,8 +768,13 @@ export class ManagedStorage {
|
||||
}
|
||||
}
|
||||
this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id);
|
||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'physical_file_cleanup', 'completed', ?, ?)")
|
||||
.run(randomUUID(), row.cleanup_id, now());
|
||||
const occurredAt = Date.now();
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'system', 'managed_storage', 'physical_file_cleanup', 'cleanup_queue_item', ?, 'succeeded', NULL, ?, ?, ?)
|
||||
`).run(randomUUID(), row.cleanup_id, serializeAuditSummary({ status: "completed" }), occurredAt, auditExpiry(occurredAt));
|
||||
this.recordPhysicalMeasurement();
|
||||
});
|
||||
finish();
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
||||
|
||||
export const modelIds = [
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"gemini-3-pro-image-preview",
|
||||
"gpt-image-2",
|
||||
] as const;
|
||||
|
||||
export type ModelId = typeof modelIds[number];
|
||||
export type ContractValidationStatus = "blocked" | "unverified" | "verified";
|
||||
export type ModelRuntimeReason =
|
||||
| "available"
|
||||
| "configured_disabled"
|
||||
| "contract_unverified"
|
||||
| "contract_blocked"
|
||||
| "gateway_balance_insufficient"
|
||||
| "gateway_paused"
|
||||
| "worker_degraded";
|
||||
|
||||
export interface ModelConfigCandidate {
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
enabled: boolean;
|
||||
is_default: boolean;
|
||||
recommendation_priority: number;
|
||||
route_profile: Record<string, unknown>;
|
||||
gateway_account_ref: string;
|
||||
error_mapping_profile: Record<string, string>;
|
||||
credit_cost: number;
|
||||
supported_ratios: string[];
|
||||
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
|
||||
prompt_max_length: number;
|
||||
safety_source: string;
|
||||
contract_validation_status?: ContractValidationStatus;
|
||||
contract_evidence_ref?: string | null;
|
||||
config_version?: number;
|
||||
}
|
||||
|
||||
export interface ModelRuntimeAvailability {
|
||||
available_for_new_jobs: boolean;
|
||||
reason: ModelRuntimeReason;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export interface ModelConfigView extends ModelConfigCandidate {
|
||||
config_version: number;
|
||||
contract_validation_status: ContractValidationStatus;
|
||||
contract_evidence_ref: string | null;
|
||||
runtime_availability: ModelRuntimeAvailability;
|
||||
}
|
||||
|
||||
export interface ModelConfigurationView {
|
||||
config_set_version: number;
|
||||
configured_default_model_id: ModelId;
|
||||
recommended_model_id: ModelId | null;
|
||||
models: ModelConfigView[];
|
||||
}
|
||||
|
||||
export class ModelConfigurationError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| "MODEL_CONFIG_VERSION_CONFLICT"
|
||||
| "MODEL_DEFAULT_REPLACEMENT_REQUIRED"
|
||||
| "MODEL_DEFAULT_REPLACEMENT_INVALID"
|
||||
| "MODEL_RECOMMENDATION_PRIORITY_INVALID"
|
||||
| "MODEL_RECOMMENDATION_PRIORITY_CONFLICT"
|
||||
| "IDEMPOTENCY_KEY_CONFLICT",
|
||||
readonly details: Record<string, unknown> = {},
|
||||
) {
|
||||
super(code);
|
||||
this.name = "ModelConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredVersion {
|
||||
config_version: number;
|
||||
contract_evidence_ref: string | null;
|
||||
contract_validation_status: ContractValidationStatus;
|
||||
credit_cost: number;
|
||||
display_name: string;
|
||||
error_mapping_profile_json: string;
|
||||
gateway_account_ref: string;
|
||||
model_id: ModelId;
|
||||
prompt_max_length: number;
|
||||
reference_limits_json: string;
|
||||
route_profile_json: string;
|
||||
safety_source: string;
|
||||
supported_ratios_json: string;
|
||||
}
|
||||
|
||||
interface StoredMember {
|
||||
config_set_id: string;
|
||||
config_version: number;
|
||||
enabled: 0 | 1;
|
||||
is_default: 0 | 1;
|
||||
model_id: ModelId;
|
||||
recommendation_priority: number;
|
||||
}
|
||||
|
||||
const defaultErrorMapping: Record<string, string> = {
|
||||
gateway_contract_invalid: "gateway_contract_invalid",
|
||||
gateway_balance_insufficient: "gateway_balance_insufficient",
|
||||
reference_invalid: "reference_invalid",
|
||||
safety_rejected: "safety_rejected",
|
||||
unknown_non_retryable: "unknown_non_retryable",
|
||||
unknown_retryable: "unknown_retryable",
|
||||
upstream_failed: "upstream_failed",
|
||||
upstream_timeout: "upstream_timeout",
|
||||
};
|
||||
|
||||
const seedCandidates: ModelConfigCandidate[] = [
|
||||
{
|
||||
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
|
||||
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
|
||||
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
|
||||
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
|
||||
},
|
||||
{
|
||||
model_id: modelIds[1], display_name: "Gemini 3 Pro Image Preview", enabled: true, is_default: false,
|
||||
recommendation_priority: 2, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
|
||||
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
|
||||
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
|
||||
},
|
||||
{
|
||||
model_id: modelIds[2], display_name: "GPT Image 2", enabled: true, is_default: false,
|
||||
recommendation_priority: 3, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
|
||||
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
|
||||
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
|
||||
},
|
||||
];
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function fingerprint(candidate: ModelConfigCandidate) {
|
||||
return createHash("sha256").update(stableJson({
|
||||
error_mapping_profile: candidate.error_mapping_profile,
|
||||
gateway_account_ref: candidate.gateway_account_ref,
|
||||
prompt_max_length: candidate.prompt_max_length,
|
||||
reference_limits: candidate.reference_limits,
|
||||
route_profile: candidate.route_profile,
|
||||
safety_source: candidate.safety_source,
|
||||
supported_ratios: candidate.supported_ratios,
|
||||
})).digest("hex");
|
||||
}
|
||||
|
||||
function profileRef(prefix: "error" | "route", value: Record<string, unknown>) {
|
||||
return `${prefix}:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function validateModelConfigurationCandidateSet(input: ModelConfigCandidate[]) {
|
||||
if (input.length !== modelIds.length || new Set(input.map((item) => item.model_id)).size !== modelIds.length
|
||||
|| input.some((item) => !modelIds.includes(item.model_id as ModelId))) {
|
||||
throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "fixed_model_set" });
|
||||
}
|
||||
for (const item of input) {
|
||||
if (!Number.isSafeInteger(item.recommendation_priority) || item.recommendation_priority <= 0) {
|
||||
throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_INVALID", {
|
||||
field_errors: [{ field: `models.${item.model_id}.recommendation_priority`, message_key: "model.priority.invalid" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
const byPriority = new Map<number, ModelConfigCandidate[]>();
|
||||
for (const item of input) byPriority.set(item.recommendation_priority, [...(byPriority.get(item.recommendation_priority) ?? []), item]);
|
||||
const duplicate = [...byPriority.values()].find((items) => items.length > 1);
|
||||
if (duplicate) {
|
||||
throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_CONFLICT", {
|
||||
conflict_model_ids: duplicate.map((item) => item.model_id),
|
||||
});
|
||||
}
|
||||
if (input.some((item) => !isPlainRecord(item.route_profile) || !isPlainRecord(item.error_mapping_profile))) {
|
||||
throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "contract_fields_invalid" });
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeFor(candidate: ModelConfigCandidate, previous: ModelRuntimeAvailability | undefined, contractChanged: boolean, now: number): ModelRuntimeAvailability {
|
||||
if (!candidate.enabled) return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "configured_disabled" };
|
||||
if (candidate.contract_validation_status === "blocked" && !contractChanged) {
|
||||
return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_blocked" };
|
||||
}
|
||||
if (candidate.contract_validation_status !== "verified" || contractChanged) {
|
||||
return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" };
|
||||
}
|
||||
return previous ?? { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" };
|
||||
}
|
||||
|
||||
export interface ModelConfigurationServiceOptions {
|
||||
clock?: () => number;
|
||||
database: BetterSqlite3.Database;
|
||||
onChanged?: (configSetVersion: number) => void;
|
||||
}
|
||||
|
||||
export class ModelConfigurationService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
readonly #clock: () => number;
|
||||
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
|
||||
|
||||
constructor(options: ModelConfigurationServiceOptions) {
|
||||
this.database = options.database;
|
||||
this.#clock = options.clock ?? Date.now;
|
||||
this.#onChanged = options.onChanged;
|
||||
this.ensureSchema();
|
||||
}
|
||||
|
||||
read(): ModelConfigurationView {
|
||||
const current = this.database.prepare(`
|
||||
SELECT c.config_set_id, c.config_set_version FROM model_config_current mc
|
||||
JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1
|
||||
`).get() as { config_set_id: string; config_set_version: number } | undefined;
|
||||
if (!current) throw new Error("model_config_current_missing");
|
||||
const rows = this.database.prepare(`
|
||||
SELECT m.model_id, m.enabled, m.is_default, m.recommendation_priority, v.*,
|
||||
r.available_for_new_jobs, r.reason, r.checked_at
|
||||
FROM model_config_set_members m
|
||||
JOIN model_config_versions v ON v.model_id = m.model_id AND v.config_version = m.config_version
|
||||
JOIN model_runtime_availability r ON r.model_id = m.model_id
|
||||
WHERE m.config_set_id = ? ORDER BY m.recommendation_priority
|
||||
`).all(current.config_set_id) as Array<StoredMember & StoredVersion & { available_for_new_jobs: 0 | 1; reason: ModelRuntimeReason; checked_at: number }>;
|
||||
const models = rows.map((row) => ({
|
||||
model_id: row.model_id,
|
||||
display_name: row.display_name,
|
||||
config_version: row.config_version,
|
||||
enabled: row.enabled === 1,
|
||||
is_default: row.is_default === 1,
|
||||
recommendation_priority: row.recommendation_priority,
|
||||
route_profile: JSON.parse(row.route_profile_json) as Record<string, unknown>,
|
||||
gateway_account_ref: row.gateway_account_ref,
|
||||
error_mapping_profile: JSON.parse(row.error_mapping_profile_json) as Record<string, string>,
|
||||
credit_cost: row.credit_cost,
|
||||
supported_ratios: JSON.parse(row.supported_ratios_json) as string[],
|
||||
reference_limits: JSON.parse(row.reference_limits_json) as ModelConfigCandidate["reference_limits"],
|
||||
prompt_max_length: row.prompt_max_length,
|
||||
safety_source: row.safety_source,
|
||||
contract_validation_status: row.contract_validation_status,
|
||||
contract_evidence_ref: row.contract_evidence_ref,
|
||||
runtime_availability: {
|
||||
available_for_new_jobs: row.available_for_new_jobs === 1,
|
||||
checked_at: new Date(row.checked_at).toISOString(),
|
||||
reason: row.reason,
|
||||
},
|
||||
} satisfies ModelConfigView));
|
||||
const recommended = models.find((model) => model.enabled
|
||||
&& model.contract_validation_status === "verified"
|
||||
&& model.runtime_availability.available_for_new_jobs);
|
||||
return {
|
||||
config_set_version: current.config_set_version,
|
||||
configured_default_model_id: models.find((model) => model.enabled && model.is_default)!.model_id as ModelId,
|
||||
recommended_model_id: recommended?.model_id ?? null,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
readModel(modelId: string) {
|
||||
return this.read().models.find((model) => model.model_id === modelId);
|
||||
}
|
||||
|
||||
replace(input: { actorId: string; expectedConfigSetVersion: number; idempotencyKey: string; models: ModelConfigCandidate[] }) {
|
||||
const requestHash = createHash("sha256").update(JSON.stringify({ expected: input.expectedConfigSetVersion, models: input.models })).digest("hex");
|
||||
const now = this.#clock();
|
||||
const result = this.database.transaction(() => {
|
||||
const existing = this.database.prepare("SELECT request_hash, response_json FROM model_config_idempotency WHERE idempotency_key = ?")
|
||||
.get(input.idempotencyKey) as { request_hash: string; response_json: string } | undefined;
|
||||
if (existing) {
|
||||
if (existing.request_hash !== requestHash) throw new ModelConfigurationError("IDEMPOTENCY_KEY_CONFLICT");
|
||||
return { replayed: true, value: JSON.parse(existing.response_json) as ModelConfigurationView };
|
||||
}
|
||||
validateModelConfigurationCandidateSet(input.models);
|
||||
const current = this.database.prepare(`
|
||||
SELECT c.config_set_id, c.config_set_version FROM model_config_current mc
|
||||
JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1
|
||||
`).get() as { config_set_id: string; config_set_version: number };
|
||||
if (input.expectedConfigSetVersion !== current.config_set_version) {
|
||||
throw new ModelConfigurationError("MODEL_CONFIG_VERSION_CONFLICT", { latest_version: current.config_set_version });
|
||||
}
|
||||
const previous = this.read();
|
||||
const oldDefault = previous.models.find((model) => model.is_default && model.enabled);
|
||||
const declaredDefaults = input.models.filter((model) => model.is_default);
|
||||
const nextDefaults = declaredDefaults.filter((model) => model.enabled);
|
||||
const nextDefault = nextDefaults[0];
|
||||
if (oldDefault && !input.models.find((model) => model.model_id === oldDefault.model_id)?.enabled) {
|
||||
if (declaredDefaults.length === 0) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_REQUIRED");
|
||||
if (!nextDefault) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID");
|
||||
if (nextDefault.model_id === oldDefault.model_id) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID");
|
||||
}
|
||||
if (nextDefaults.length !== 1) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "enabled_default_count" });
|
||||
const setId = randomUUID();
|
||||
const nextSetVersion = current.config_set_version + 1;
|
||||
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, ?, ?, ?)")
|
||||
.run(setId, nextSetVersion, now, input.actorId);
|
||||
const insertVersion = this.database.prepare(`
|
||||
INSERT INTO model_config_versions (
|
||||
model_id, config_version, display_name, enabled, is_default, recommendation_priority,
|
||||
route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json,
|
||||
credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source,
|
||||
contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertMember = this.database.prepare(`
|
||||
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const candidate of input.models) {
|
||||
const old = previous.models.find((model) => model.model_id === candidate.model_id);
|
||||
const changed = !old || fingerprint(candidate) !== fingerprint(old) || candidate.display_name !== old.display_name
|
||||
|| candidate.credit_cost !== old.credit_cost || candidate.enabled !== old.enabled
|
||||
|| candidate.is_default !== old.is_default || candidate.recommendation_priority !== old.recommendation_priority
|
||||
|| (candidate.contract_validation_status !== undefined && candidate.contract_validation_status !== old.contract_validation_status);
|
||||
const contractChanged = Boolean(old && fingerprint(candidate) !== fingerprint(old));
|
||||
const configVersion = changed ? (old?.config_version ?? 0) + 1 : old!.config_version;
|
||||
const status: ContractValidationStatus = contractChanged
|
||||
? "unverified"
|
||||
: candidate.contract_validation_status ?? old?.contract_validation_status ?? "unverified";
|
||||
const evidence = status === "unverified" ? null : candidate.contract_evidence_ref ?? old?.contract_evidence_ref ?? null;
|
||||
if (changed) {
|
||||
const routeProfileId = profileRef("route", candidate.route_profile);
|
||||
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
||||
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||
.run(routeProfileId, stableJson(candidate.route_profile), now);
|
||||
this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||
.run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now);
|
||||
insertVersion.run(
|
||||
candidate.model_id, configVersion, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
||||
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
||||
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios),
|
||||
stableJson(candidate.reference_limits), candidate.prompt_max_length, candidate.safety_source, status, evidence, fingerprint(candidate), now,
|
||||
);
|
||||
}
|
||||
insertMember.run(setId, candidate.model_id, configVersion, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
||||
const runtime = runtimeFor({ ...candidate, contract_validation_status: status }, old?.runtime_availability, contractChanged, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(runtime_availability_version), 0) + 1 FROM model_runtime_availability))
|
||||
ON CONFLICT(model_id) DO UPDATE SET available_for_new_jobs=excluded.available_for_new_jobs, reason=excluded.reason,
|
||||
checked_at=excluded.checked_at, runtime_availability_version=excluded.runtime_availability_version
|
||||
`).run(candidate.model_id, runtime.available_for_new_jobs ? 1 : 0, runtime.reason, now);
|
||||
}
|
||||
this.database.prepare("UPDATE model_config_current SET config_set_id = ? WHERE singleton = 1").run(setId);
|
||||
const after = this.read();
|
||||
const occurredAt = now;
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref, result,
|
||||
before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'super_admin', ?, 'model_configuration_replace', 'model_config_set', ?, 'succeeded', ?, ?, ?, ?)
|
||||
`).run(
|
||||
randomUUID(), input.actorId, setId,
|
||||
serializeAuditSummary({ config_set_version: current.config_set_version }),
|
||||
serializeAuditSummary({ config_set_version: nextSetVersion, model_count: after.models.length }), occurredAt, occurredAt + auditRetentionMilliseconds,
|
||||
);
|
||||
this.database.prepare(`
|
||||
INSERT INTO outbox_events (event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at)
|
||||
VALUES (?, ?, 'model_config_changed', 'model_config_set', ?, ?, 'pending', ?, NULL)
|
||||
`).run(randomUUID(), `model-config:${input.idempotencyKey}`, setId, JSON.stringify({ config_set_version: nextSetVersion }), now);
|
||||
this.database.prepare("INSERT INTO model_config_idempotency (idempotency_key, request_hash, response_json, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(input.idempotencyKey, requestHash, JSON.stringify(after), now);
|
||||
return { replayed: false, value: after };
|
||||
}).immediate();
|
||||
if (!result.replayed) this.#onChanged?.(result.value.config_set_version);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
private ensureSchema() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gateway_route_profiles (
|
||||
route_profile_id TEXT PRIMARY KEY,
|
||||
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS error_mapping_profiles (
|
||||
error_mapping_profile_id TEXT PRIMARY KEY,
|
||||
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_config_sets (
|
||||
config_set_id TEXT PRIMARY KEY,
|
||||
config_set_version INTEGER NOT NULL UNIQUE CHECK (config_set_version > 0),
|
||||
created_at INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_config_versions (
|
||||
model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')),
|
||||
config_version INTEGER NOT NULL CHECK (config_version > 0),
|
||||
display_name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)),
|
||||
recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0),
|
||||
route_profile_id TEXT NOT NULL REFERENCES gateway_route_profiles(route_profile_id),
|
||||
route_profile_json TEXT NOT NULL CHECK (json_valid(route_profile_json)),
|
||||
gateway_account_ref TEXT NOT NULL,
|
||||
error_mapping_profile_id TEXT NOT NULL REFERENCES error_mapping_profiles(error_mapping_profile_id),
|
||||
error_mapping_profile_json TEXT NOT NULL CHECK (json_valid(error_mapping_profile_json)),
|
||||
credit_cost INTEGER NOT NULL CHECK (credit_cost > 0),
|
||||
supported_ratios_json TEXT NOT NULL CHECK (json_valid(supported_ratios_json)),
|
||||
reference_limits_json TEXT NOT NULL CHECK (json_valid(reference_limits_json)),
|
||||
prompt_max_length INTEGER NOT NULL CHECK (prompt_max_length > 0),
|
||||
safety_source TEXT NOT NULL,
|
||||
contract_validation_status TEXT NOT NULL CHECK (contract_validation_status IN ('blocked', 'unverified', 'verified')),
|
||||
contract_evidence_ref TEXT,
|
||||
contract_fingerprint TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (model_id, config_version)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_config_set_members (
|
||||
config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id),
|
||||
model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')),
|
||||
config_version INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)),
|
||||
recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0),
|
||||
PRIMARY KEY (config_set_id, model_id),
|
||||
FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_priority_unique
|
||||
ON model_config_set_members(config_set_id, recommendation_priority);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_enabled_default_unique
|
||||
ON model_config_set_members(config_set_id) WHERE enabled = 1 AND is_default = 1;
|
||||
CREATE TABLE IF NOT EXISTS model_config_current (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_runtime_availability (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||
checked_at INTEGER NOT NULL,
|
||||
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_config_idempotency (
|
||||
idempotency_key TEXT PRIMARY KEY,
|
||||
request_hash TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
operation_key TEXT NOT NULL UNIQUE,
|
||||
topic TEXT NOT NULL,
|
||||
aggregate_type TEXT NOT NULL,
|
||||
aggregate_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
published_at INTEGER
|
||||
);
|
||||
`);
|
||||
this.database.exec(`
|
||||
DROP TRIGGER IF EXISTS model_config_current_default_guard_insert;
|
||||
DROP TRIGGER IF EXISTS model_config_current_default_guard_update;
|
||||
CREATE TRIGGER model_config_current_default_guard_insert
|
||||
BEFORE INSERT ON model_config_current
|
||||
WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1
|
||||
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3
|
||||
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id
|
||||
AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END;
|
||||
CREATE TRIGGER model_config_current_default_guard_update
|
||||
BEFORE UPDATE OF config_set_id ON model_config_current
|
||||
WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1
|
||||
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3
|
||||
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id
|
||||
AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_config_sets_no_update BEFORE UPDATE ON model_config_sets
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_config_sets_no_delete BEFORE DELETE ON model_config_sets
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_config_versions_no_update BEFORE UPDATE ON model_config_versions
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_config_versions_no_delete BEFORE DELETE ON model_config_versions
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_config_members_no_update BEFORE UPDATE ON model_config_set_members
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_config_members_no_delete BEFORE DELETE ON model_config_set_members
|
||||
BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_update BEFORE UPDATE ON gateway_route_profiles
|
||||
BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_delete BEFORE DELETE ON gateway_route_profiles
|
||||
BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_update BEFORE UPDATE ON error_mapping_profiles
|
||||
BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_delete BEFORE DELETE ON error_mapping_profiles
|
||||
BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END;
|
||||
`);
|
||||
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
|
||||
if (current) return;
|
||||
const seed = this.database.transaction(() => {
|
||||
validateModelConfigurationCandidateSet(seedCandidates);
|
||||
const now = this.#clock();
|
||||
const setId = randomUUID();
|
||||
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
|
||||
.run(setId, now);
|
||||
const insertVersion = this.database.prepare(`
|
||||
INSERT INTO model_config_versions (
|
||||
model_id, config_version, display_name, enabled, is_default, recommendation_priority,
|
||||
route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json,
|
||||
credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source,
|
||||
contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertMember = this.database.prepare(`
|
||||
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
||||
VALUES (?, ?, 1, ?, ?, ?)
|
||||
`);
|
||||
for (const candidate of seedCandidates) {
|
||||
const routeProfileId = profileRef("route", candidate.route_profile);
|
||||
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
||||
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||
.run(routeProfileId, stableJson(candidate.route_profile), now);
|
||||
this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||
.run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now);
|
||||
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
||||
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
||||
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
|
||||
candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now);
|
||||
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
||||
this.database.prepare(`
|
||||
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||
VALUES (?, 0, 'contract_unverified', ?, 0)
|
||||
`).run(candidate.model_id, now);
|
||||
}
|
||||
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
|
||||
});
|
||||
seed.immediate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { ModelConfigurationService, modelIds, type ModelConfigCandidate } from "./model-configuration.js";
|
||||
|
||||
const safeRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||
const requiredRatios = ["3:4", "1:1", "4:3", "9:16"];
|
||||
const requiredAdapterErrors = [
|
||||
"upstream_timeout", "upstream_failed", "safety_rejected", "gateway_balance_insufficient",
|
||||
"gateway_contract_invalid", "reference_invalid", "unknown_retryable", "unknown_non_retryable",
|
||||
];
|
||||
const forbiddenKeyFragments = ["credential", "password", "prompt_text", "raw", "secret", "token"];
|
||||
|
||||
export interface ModelContractEvidenceInput {
|
||||
evidence_hash: string;
|
||||
evidence_ref: string;
|
||||
matrix: unknown;
|
||||
model_id: string;
|
||||
verified_at: string;
|
||||
verifier_ref: string;
|
||||
}
|
||||
|
||||
function hasForbiddenEvidenceKey(value: unknown, depth = 0): boolean {
|
||||
if (depth > 8 || !value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) return value.some((entry) => hasForbiddenEvidenceKey(entry, depth + 1));
|
||||
return Object.entries(value).some(([key, entry]) => (
|
||||
forbiddenKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||
|| hasForbiddenEvidenceKey(entry, depth + 1)
|
||||
));
|
||||
}
|
||||
|
||||
function validateEvidence(input: ModelContractEvidenceInput) {
|
||||
if (!modelIds.includes(input.model_id as typeof modelIds[number])) throw new Error("contract_evidence_model_invalid");
|
||||
if (!safeRefPattern.test(input.evidence_hash) || !safeRefPattern.test(input.evidence_ref) || !safeRefPattern.test(input.verifier_ref)) {
|
||||
throw new Error("contract_evidence_reference_invalid");
|
||||
}
|
||||
const verifiedAt = Date.parse(input.verified_at);
|
||||
if (!Number.isFinite(verifiedAt) || new Date(verifiedAt).toISOString() !== input.verified_at) throw new Error("contract_evidence_time_invalid");
|
||||
if (hasForbiddenEvidenceKey(input.matrix)) throw new Error("contract_evidence_sensitive_field");
|
||||
if (!input.matrix || typeof input.matrix !== "object") throw new Error("contract_evidence_matrix_incomplete");
|
||||
const matrix = input.matrix as Record<string, unknown>;
|
||||
const passedSingleOutput = (value: unknown) => Boolean(value && typeof value === "object"
|
||||
&& "status" in value && value.status === "passed" && "outputs" in value && value.outputs === 1);
|
||||
const ratios = Array.isArray(matrix.ratios) ? matrix.ratios as Array<Record<string, unknown>> : [];
|
||||
const ratioNames = ratios.filter(passedSingleOutput).map((entry) => entry.ratio).toSorted();
|
||||
const executionModes = Array.isArray(matrix.execution_modes) ? matrix.execution_modes : [];
|
||||
const errorMapping = Array.isArray(matrix.error_mapping) ? matrix.error_mapping : [];
|
||||
const executionComplete = executionModes.includes("sync") || (executionModes.includes("async") && executionModes.includes("poll"));
|
||||
if (matrix.model_id !== input.model_id || !passedSingleOutput(matrix.pure_text) || !passedSingleOutput(matrix.reference_image)
|
||||
|| JSON.stringify(ratioNames) !== JSON.stringify([...requiredRatios].toSorted()) || !executionComplete
|
||||
|| !requiredAdapterErrors.every((category) => errorMapping.includes(category))) {
|
||||
throw new Error("contract_evidence_matrix_incomplete");
|
||||
}
|
||||
return verifiedAt;
|
||||
}
|
||||
|
||||
function editableCandidates(models: ReturnType<ModelConfigurationService["read"]>["models"]): ModelConfigCandidate[] {
|
||||
return models.map(({ config_version: _configVersion, runtime_availability: _runtime, ...candidate }) => structuredClone(candidate));
|
||||
}
|
||||
|
||||
export class ModelContractEvidenceService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
private readonly models: ModelConfigurationService;
|
||||
|
||||
constructor(input: { clock?: () => number; database: BetterSqlite3.Database; models: ModelConfigurationService }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.database = input.database;
|
||||
this.models = input.models;
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
recordVerified(input: {
|
||||
actorId: string;
|
||||
evidence: ModelContractEvidenceInput;
|
||||
expectedConfigSetVersion: number;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
const verifiedAt = validateEvidence(input.evidence);
|
||||
return this.database.transaction(() => {
|
||||
const current = this.models.read();
|
||||
if (current.config_set_version !== input.expectedConfigSetVersion) throw new Error("contract_evidence_config_set_conflict");
|
||||
const existingHash = this.database.prepare("SELECT model_id FROM model_contract_evidence WHERE evidence_hash = ?").get(input.evidence.evidence_hash) as { model_id: string } | undefined;
|
||||
if (existingHash && existingHash.model_id !== input.evidence.model_id) throw new Error("contract_evidence_shared_between_models");
|
||||
const candidates = editableCandidates(current.models);
|
||||
const target = candidates.find((model) => model.model_id === input.evidence.model_id)!;
|
||||
target.contract_validation_status = "verified";
|
||||
target.contract_evidence_ref = input.evidence.evidence_ref;
|
||||
const configuration = this.models.replace({
|
||||
actorId: input.actorId,
|
||||
expectedConfigSetVersion: input.expectedConfigSetVersion,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
models: candidates,
|
||||
});
|
||||
const model = configuration.models.find((candidate) => candidate.model_id === input.evidence.model_id)!;
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO model_contract_evidence (
|
||||
model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.evidence.model_id, model.config_version, input.evidence.evidence_hash, input.evidence.evidence_ref,
|
||||
input.evidence.verifier_ref, verifiedAt, JSON.stringify(input.evidence.matrix), this.clock(),
|
||||
);
|
||||
return { configuration, evidence: structuredClone(input.evidence), model };
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
read(modelId: string, configVersion: number) {
|
||||
return this.database.prepare(`
|
||||
SELECT model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json
|
||||
FROM model_contract_evidence WHERE model_id = ? AND config_version = ?
|
||||
`).get(modelId, configVersion) as {
|
||||
config_version: number; evidence_hash: string; evidence_json: string; evidence_ref: string;
|
||||
model_id: string; verified_at: number; verifier_ref: string;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS model_contract_evidence (
|
||||
model_id TEXT NOT NULL,
|
||||
config_version INTEGER NOT NULL,
|
||||
evidence_hash TEXT NOT NULL UNIQUE,
|
||||
evidence_ref TEXT NOT NULL,
|
||||
verifier_ref TEXT NOT NULL,
|
||||
verified_at INTEGER NOT NULL,
|
||||
evidence_json TEXT NOT NULL CHECK (json_valid(evidence_json)),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (model_id, config_version),
|
||||
FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version)
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_update BEFORE UPDATE ON model_contract_evidence
|
||||
BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_delete BEFORE DELETE ON model_contract_evidence
|
||||
BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ProjectErrorCode =
|
||||
| "generation_state_invalid"
|
||||
| "project_active_limit"
|
||||
| "project_history_limit"
|
||||
| "project_name_invalid"
|
||||
| "project_not_found"
|
||||
| "project_ratio_fixed"
|
||||
| "project_retry_not_allowed"
|
||||
| "project_state_conflict"
|
||||
| "project_state_invalid"
|
||||
| "project_state_idempotency_conflict";
|
||||
|
||||
export class ProjectError extends Error {
|
||||
readonly code: ProjectErrorCode;
|
||||
readonly latestStateVersion: number | undefined;
|
||||
|
||||
constructor(code: ProjectErrorCode, latestStateVersion?: number) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
this.latestStateVersion = latestStateVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
import { isProjectEditableState, type CanvasState, type ProjectEditableState } from "@dada/shared-contracts";
|
||||
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
export { ProjectError } from "./project-errors.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
|
||||
export type ProjectRatio = typeof projectRatios[number];
|
||||
export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
|
||||
export type ProjectManagedResourceKind = "derived" | "export" | "generated" | "reference";
|
||||
export const ratioPixels: Record<ProjectRatio, { height: number; width: number }> = {
|
||||
"3:4": { height: 1440, width: 1080 },
|
||||
"1:1": { height: 1080, width: 1080 },
|
||||
"4:3": { height: 1080, width: 1440 },
|
||||
"9:16": { height: 1920, width: 1080 },
|
||||
};
|
||||
export const projectLimit = 20;
|
||||
export const historyLimit = 10;
|
||||
const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000;
|
||||
const generationErrorCategories = new Set([
|
||||
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
|
||||
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
|
||||
"unknown_retryable", "unknown_non_retryable",
|
||||
]);
|
||||
|
||||
interface ProjectRow {
|
||||
created_at: number;
|
||||
current_image_id: string | null;
|
||||
deleted_at: number | null;
|
||||
draft_prompt: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
pixel_height: number;
|
||||
pixel_width: number;
|
||||
project_id: string;
|
||||
purge_at: number | null;
|
||||
ratio: ProjectRatio;
|
||||
state_version: number;
|
||||
status: "active" | "trashed" | "purged";
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface GenerationRow {
|
||||
created_at: number;
|
||||
error_category: string | null;
|
||||
generation_id: string;
|
||||
prompt: string;
|
||||
project_id: string;
|
||||
ratio: ProjectRatio;
|
||||
status: GenerationStatus;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface ImageRow {
|
||||
created_at: number;
|
||||
generation_id: string;
|
||||
image_id: string;
|
||||
}
|
||||
|
||||
interface ProjectStateRow {
|
||||
canvas_json: string;
|
||||
created_at: number;
|
||||
name: string;
|
||||
project_id: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
interface LatestExportRow {
|
||||
byte_size: number;
|
||||
created_at: number;
|
||||
export_id: string;
|
||||
format: "jpg" | "png";
|
||||
pixel_height: number;
|
||||
pixel_width: number;
|
||||
sha256: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
function isProjectRatio(value: string): value is ProjectRatio {
|
||||
return projectRatios.includes(value as ProjectRatio);
|
||||
}
|
||||
|
||||
export function normalizePrompt(value: string) {
|
||||
const normalized = value.trim().replace(/\s+/gu, " ");
|
||||
if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function takeGraphemes(value: string, count: number) {
|
||||
const Segmenter = Intl.Segmenter;
|
||||
if (Segmenter) {
|
||||
return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)]
|
||||
.slice(0, count)
|
||||
.map((entry) => entry.segment)
|
||||
.join("");
|
||||
}
|
||||
return Array.from(value).slice(0, count).join("");
|
||||
}
|
||||
|
||||
function localDate(timestamp: number) {
|
||||
const date = new Date(timestamp);
|
||||
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
|
||||
.map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0"))
|
||||
.join("-");
|
||||
}
|
||||
|
||||
export function defaultProjectName(prompt: string, timestamp: number) {
|
||||
const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作";
|
||||
return `${summary} ${localDate(timestamp)}`;
|
||||
}
|
||||
|
||||
function normalizeProjectName(value: string) {
|
||||
const normalized = value.trim().replace(/\s+/gu, " ");
|
||||
if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
export function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState {
|
||||
return {
|
||||
background: {
|
||||
adjustments: {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
crop: null,
|
||||
filter: "none",
|
||||
fit: "fill",
|
||||
saturation: 0,
|
||||
sharpness: 0,
|
||||
temperature: 0,
|
||||
},
|
||||
asset_id: assetId,
|
||||
},
|
||||
elements: [],
|
||||
pixel_height: pixels.height,
|
||||
pixel_width: pixels.width,
|
||||
ratio,
|
||||
schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export class ProjectService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(input: { clock?: () => number; databasePath: string }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("synchronous = FULL");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
createProjectForGeneration(input: {
|
||||
ownerId: string;
|
||||
prompt: string;
|
||||
ratio: ProjectRatio;
|
||||
status: Exclude<GenerationStatus, "succeeded">;
|
||||
}) {
|
||||
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
|
||||
const prompt = normalizePrompt(input.prompt);
|
||||
const projectId = randomUUID();
|
||||
const generationId = randomUUID();
|
||||
const now = this.clock();
|
||||
const pixels = ratioPixels[input.ratio];
|
||||
const transaction = this.database.transaction(() => {
|
||||
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||
.get(input.ownerId) as { count: number };
|
||||
if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
|
||||
this.database.prepare(`
|
||||
INSERT INTO projects (
|
||||
project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height,
|
||||
status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL)
|
||||
`).run(
|
||||
projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio,
|
||||
pixels.width, pixels.height, now, now,
|
||||
);
|
||||
this.insertProjectState({
|
||||
canvasState: defaultCanvasState(input.ratio, pixels, null),
|
||||
name: defaultProjectName(prompt, now),
|
||||
projectId,
|
||||
stateVersion: 1,
|
||||
}, now);
|
||||
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) };
|
||||
}
|
||||
|
||||
continueProjectGeneration(input: {
|
||||
ownerId: string;
|
||||
projectId: string;
|
||||
prompt: string;
|
||||
ratio: ProjectRatio;
|
||||
status: Exclude<GenerationStatus, "succeeded">;
|
||||
}) {
|
||||
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
|
||||
const generationId = randomUUID();
|
||||
const prompt = normalizePrompt(input.prompt);
|
||||
const now = this.clock();
|
||||
const transaction = this.database.transaction(() => {
|
||||
const project = this.readOwnedProject(input.ownerId, input.projectId);
|
||||
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||
if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed");
|
||||
if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit");
|
||||
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now);
|
||||
this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?")
|
||||
.run(prompt, now, input.projectId);
|
||||
});
|
||||
transaction.immediate();
|
||||
return this.readGeneration(generationId);
|
||||
}
|
||||
|
||||
retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) {
|
||||
const project = this.getProject(input.ownerId, input.projectId);
|
||||
const latest = project.generations.at(-1);
|
||||
if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) {
|
||||
throw new ProjectError("project_retry_not_allowed");
|
||||
}
|
||||
return this.continueProjectGeneration({
|
||||
ownerId: input.ownerId,
|
||||
projectId: input.projectId,
|
||||
prompt: input.prompt,
|
||||
ratio: project.ratio,
|
||||
status: "queued",
|
||||
});
|
||||
}
|
||||
|
||||
recordSuccessfulImage(input: { generationId: string; imageId: string }) {
|
||||
const now = this.clock();
|
||||
const transaction = this.database.transaction(() => {
|
||||
const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?")
|
||||
.get(input.generationId) as GenerationRow | undefined;
|
||||
if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid");
|
||||
if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit");
|
||||
const project = this.database.prepare("SELECT * FROM projects WHERE project_id = ?").get(generation.project_id) as ProjectRow;
|
||||
const currentState = this.readProjectState(generation.project_id);
|
||||
const nextCanvas = structuredClone(currentState.canvasState);
|
||||
if (project.current_image_id === null) nextCanvas.background.asset_id = input.imageId;
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ?
|
||||
`).run(now, input.generationId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)
|
||||
`).run(input.imageId, generation.project_id, input.generationId, now);
|
||||
this.database.prepare(`
|
||||
UPDATE projects
|
||||
SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
|
||||
WHERE project_id = ?
|
||||
`).run(input.imageId, now, generation.project_id);
|
||||
this.insertProjectState({
|
||||
canvasState: nextCanvas,
|
||||
name: project.name,
|
||||
projectId: generation.project_id,
|
||||
stateVersion: project.state_version + 1,
|
||||
}, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
}
|
||||
|
||||
markGenerationFailed(generationId: string, errorCategory: string) {
|
||||
if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid");
|
||||
const status = errorCategory === "safety_rejected" ? "rejected" : "failed";
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ?
|
||||
WHERE generation_id = ? AND status IN ('queued', 'running')
|
||||
`).run(status, errorCategory, this.clock(), generationId);
|
||||
if (changed.changes !== 1) throw new ProjectError("generation_state_invalid");
|
||||
}
|
||||
|
||||
renameProject(ownerId: string, projectId: string, name: string) {
|
||||
const normalized = normalizeProjectName(name);
|
||||
const now = this.clock();
|
||||
const transaction = this.database.transaction(() => {
|
||||
const project = this.readOwnedProject(ownerId, projectId);
|
||||
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||
const currentState = this.readProjectState(projectId);
|
||||
this.database.prepare(`
|
||||
UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1
|
||||
WHERE owner_id = ? AND project_id = ? AND status = 'active'
|
||||
`).run(normalized, now, ownerId, projectId);
|
||||
this.insertProjectState({
|
||||
canvasState: currentState.canvasState,
|
||||
name: normalized,
|
||||
projectId,
|
||||
stateVersion: project.state_version + 1,
|
||||
}, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version };
|
||||
}
|
||||
|
||||
saveProjectState(input: {
|
||||
expectedStateVersion: number;
|
||||
idempotencyKey: string;
|
||||
ownerId: string;
|
||||
projectId: string;
|
||||
state: ProjectEditableState;
|
||||
}) {
|
||||
if (!Number.isSafeInteger(input.expectedStateVersion) || input.expectedStateVersion < 1 || !isProjectEditableState(input.state)) {
|
||||
throw new ProjectError("project_state_invalid");
|
||||
}
|
||||
const name = normalizeProjectName(input.state.name);
|
||||
const state = { canvas_state: structuredClone(input.state.canvas_state), name } satisfies ProjectEditableState;
|
||||
const requestHash = createHash("sha256").update(stableJson({ expected: input.expectedStateVersion, state })).digest("hex");
|
||||
const now = this.clock();
|
||||
let result!: { stateVersion: number };
|
||||
const transaction = this.database.transaction(() => {
|
||||
const replay = this.database.prepare(`
|
||||
SELECT request_hash, response_state_version FROM project_state_idempotency
|
||||
WHERE owner_id = ? AND project_id = ? AND idempotency_key = ?
|
||||
`).get(input.ownerId, input.projectId, input.idempotencyKey) as { request_hash: string; response_state_version: number } | undefined;
|
||||
if (replay) {
|
||||
if (replay.request_hash !== requestHash) throw new ProjectError("project_state_idempotency_conflict");
|
||||
result = { stateVersion: replay.response_state_version };
|
||||
return;
|
||||
}
|
||||
const project = this.readOwnedProject(input.ownerId, input.projectId);
|
||||
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||
if (project.state_version !== input.expectedStateVersion) {
|
||||
throw new ProjectError("project_state_conflict", project.state_version);
|
||||
}
|
||||
const canvas = state.canvas_state;
|
||||
if (canvas.ratio !== project.ratio || canvas.pixel_width !== project.pixel_width || canvas.pixel_height !== project.pixel_height) {
|
||||
throw new ProjectError("project_state_invalid");
|
||||
}
|
||||
if (canvas.background.asset_id) {
|
||||
const owned = this.database.prepare("SELECT 1 FROM project_images WHERE project_id = ? AND image_id = ?")
|
||||
.get(input.projectId, canvas.background.asset_id);
|
||||
if (!owned) throw new ProjectError("project_state_invalid");
|
||||
}
|
||||
const nextVersion = project.state_version + 1;
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE projects SET name = ?, state_version = ?, updated_at = ?
|
||||
WHERE project_id = ? AND owner_id = ? AND state_version = ? AND status = 'active'
|
||||
`).run(name, nextVersion, now, input.projectId, input.ownerId, input.expectedStateVersion);
|
||||
if (changed.changes !== 1) {
|
||||
const latest = this.readOwnedProject(input.ownerId, input.projectId).state_version;
|
||||
throw new ProjectError("project_state_conflict", latest);
|
||||
}
|
||||
this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_state_idempotency (
|
||||
owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(input.ownerId, input.projectId, input.idempotencyKey, requestHash, nextVersion, now);
|
||||
result = { stateVersion: nextVersion };
|
||||
});
|
||||
transaction.immediate();
|
||||
return result;
|
||||
}
|
||||
|
||||
trashFailedEmpty(ownerId: string, projectIds: string[]) {
|
||||
const uniqueIds = [...new Set(projectIds)];
|
||||
if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
|
||||
const trashedProjectIds: string[] = [];
|
||||
const ignoredProjectIds: string[] = [];
|
||||
const now = this.clock();
|
||||
const transaction = this.database.transaction(() => {
|
||||
for (const projectId of uniqueIds) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT p.project_id,
|
||||
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
|
||||
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
|
||||
FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active'
|
||||
`).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined;
|
||||
if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) {
|
||||
ignoredProjectIds.push(projectId);
|
||||
continue;
|
||||
}
|
||||
this.database.prepare(`
|
||||
UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
|
||||
WHERE project_id = ?
|
||||
`).run(now, now + trashRetentionMilliseconds, now, projectId);
|
||||
trashedProjectIds.push(projectId);
|
||||
}
|
||||
});
|
||||
transaction.immediate();
|
||||
return { ignoredProjectIds, trashedProjectIds };
|
||||
}
|
||||
|
||||
trashProject(ownerId: string, projectId: string) {
|
||||
const now = this.clock();
|
||||
let purged = false;
|
||||
let result!: ReturnType<ProjectService["projectSummary"]>;
|
||||
const transaction = this.database.transaction(() => {
|
||||
const project = this.readOwnedProject(ownerId, projectId);
|
||||
if (project.status === "purged") throw new ProjectError("project_not_found");
|
||||
if (project.status === "trashed") {
|
||||
if (project.purge_at !== null && project.purge_at <= now) {
|
||||
this.transitionToPurged(project, now);
|
||||
purged = true;
|
||||
return;
|
||||
}
|
||||
result = this.projectSummary(project, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
|
||||
return;
|
||||
}
|
||||
this.database.prepare(`
|
||||
UPDATE projects
|
||||
SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
|
||||
WHERE project_id = ? AND owner_id = ? AND status = 'active'
|
||||
`).run(now, now + trashRetentionMilliseconds, now, projectId, ownerId);
|
||||
const updated = this.readOwnedProject(ownerId, projectId);
|
||||
result = this.projectSummary(updated, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
|
||||
});
|
||||
transaction.immediate();
|
||||
if (purged) throw new ProjectError("project_not_found");
|
||||
return result;
|
||||
}
|
||||
|
||||
restoreProject(ownerId: string, projectId: string) {
|
||||
const now = this.clock();
|
||||
let purged = false;
|
||||
const transaction = this.database.transaction(() => {
|
||||
const project = this.readOwnedProject(ownerId, projectId);
|
||||
if (project.status === "purged") throw new ProjectError("project_not_found");
|
||||
if (project.status === "active") return;
|
||||
if (project.purge_at !== null && project.purge_at <= now) {
|
||||
this.transitionToPurged(project, now);
|
||||
purged = true;
|
||||
return;
|
||||
}
|
||||
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||
.get(ownerId) as { count: number };
|
||||
if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
|
||||
this.database.prepare(`
|
||||
UPDATE projects
|
||||
SET status = 'active', deleted_at = NULL, purge_at = NULL, updated_at = ?, state_version = state_version + 1
|
||||
WHERE project_id = ? AND owner_id = ? AND status = 'trashed'
|
||||
`).run(now, projectId, ownerId);
|
||||
});
|
||||
transaction.immediate();
|
||||
if (purged) throw new ProjectError("project_not_found");
|
||||
return this.getProject(ownerId, projectId);
|
||||
}
|
||||
|
||||
purgeProject(ownerId: string, projectId: string) {
|
||||
const now = this.clock();
|
||||
const transaction = this.database.transaction(() => {
|
||||
const project = this.readOwnedProject(ownerId, projectId);
|
||||
if (project.status !== "trashed") throw new ProjectError("project_not_found");
|
||||
this.transitionToPurged(project, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
return { projectId, status: "purged" as const };
|
||||
}
|
||||
|
||||
linkManagedResource(ownerId: string, projectId: string, managedFileId: string, resourceKind: ProjectManagedResourceKind) {
|
||||
const project = this.readOwnedProject(ownerId, projectId);
|
||||
if (project.status === "purged" || !["derived", "export", "generated", "reference"].includes(resourceKind)) {
|
||||
throw new ProjectError("project_not_found");
|
||||
}
|
||||
if (!this.tableExists("managed_files") || !this.tableExists("project_asset_refs")) {
|
||||
throw new ProjectError("project_state_invalid");
|
||||
}
|
||||
const managed = this.database.prepare(`
|
||||
SELECT file_id FROM managed_files WHERE file_id = ? AND owner_ref = ? AND status = 'committed'
|
||||
`).get(managedFileId, ownerId);
|
||||
if (!managed) throw new ProjectError("project_state_invalid");
|
||||
const now = this.clock();
|
||||
const transaction = this.database.transaction(() => {
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(projectId, managedFileId, resourceKind, now);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
|
||||
VALUES (?, ?, 'project', ?)
|
||||
`).run(`project:${projectId}:${managedFileId}`, managedFileId, iso(now));
|
||||
});
|
||||
transaction.immediate();
|
||||
}
|
||||
|
||||
listProjects(ownerId: string, status: "active" | "trashed") {
|
||||
this.purgeExpiredProjects(ownerId);
|
||||
const rows = this.database.prepare(`
|
||||
SELECT p.*,
|
||||
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
|
||||
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
|
||||
FROM projects p
|
||||
WHERE p.owner_id = ? AND p.status = ?
|
||||
ORDER BY p.updated_at DESC, p.project_id DESC
|
||||
`).all(ownerId, status) as Array<ProjectRow & { image_count: number; latest_status: string | null }>;
|
||||
return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status));
|
||||
}
|
||||
|
||||
getProject(ownerId: string, projectId: string) {
|
||||
this.purgeExpiredProjects(ownerId, projectId);
|
||||
const row = this.readOwnedProject(ownerId, projectId);
|
||||
if (row.status === "purged") throw new ProjectError("project_not_found");
|
||||
const generations = this.database.prepare(`
|
||||
SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid
|
||||
`).all(projectId) as GenerationRow[];
|
||||
const images = this.database.prepare(`
|
||||
SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid
|
||||
`).all(projectId) as ImageRow[];
|
||||
const latestExports = this.database.prepare(`
|
||||
SELECT export_id, format, sha256, byte_size, pixel_width, pixel_height, state_version, created_at
|
||||
FROM latest_exports WHERE project_id = ? ORDER BY format
|
||||
`).all(projectId) as LatestExportRow[];
|
||||
const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null);
|
||||
const projectState = this.readProjectState(projectId);
|
||||
return {
|
||||
...summary,
|
||||
canvasState: projectState.canvasState,
|
||||
createdAt: iso(row.created_at),
|
||||
draftPrompt: row.draft_prompt,
|
||||
generations: generations.map((generation) => this.generationView(generation)),
|
||||
images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })),
|
||||
latestExports: latestExports.map((item) => ({
|
||||
byteSize: item.byte_size,
|
||||
createdAt: iso(item.created_at),
|
||||
downloadUrl: `/api/v1/projects/${projectId}/latest-exports/${item.format}`,
|
||||
exportId: item.export_id,
|
||||
format: item.format,
|
||||
pixelHeight: item.pixel_height,
|
||||
pixelWidth: item.pixel_width,
|
||||
sha256: item.sha256,
|
||||
stateVersion: item.state_version,
|
||||
})),
|
||||
pixelHeight: row.pixel_height,
|
||||
pixelWidth: row.pixel_width,
|
||||
saveStatus: "saved" as const,
|
||||
};
|
||||
}
|
||||
|
||||
activeProjectCount(ownerId: string) {
|
||||
const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||
.get(ownerId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
private insertGeneration(input: {
|
||||
generationId: string;
|
||||
ownerId: string;
|
||||
projectId: string;
|
||||
prompt: string;
|
||||
ratio: ProjectRatio;
|
||||
status: Exclude<GenerationStatus, "succeeded">;
|
||||
}, now: number) {
|
||||
const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null;
|
||||
this.database.prepare(`
|
||||
INSERT INTO generation_jobs (
|
||||
generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now);
|
||||
}
|
||||
|
||||
private readGeneration(generationId: string) {
|
||||
const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined;
|
||||
if (!row) throw new ProjectError("generation_state_invalid");
|
||||
return this.generationView(row);
|
||||
}
|
||||
|
||||
private generationView(row: GenerationRow) {
|
||||
return {
|
||||
createdAt: iso(row.created_at),
|
||||
errorCategory: row.error_category,
|
||||
generationId: row.generation_id,
|
||||
projectId: row.project_id,
|
||||
prompt: row.prompt,
|
||||
ratio: row.ratio,
|
||||
status: row.status,
|
||||
updatedAt: iso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private readOwnedProject(ownerId: string, projectId: string) {
|
||||
const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?")
|
||||
.get(ownerId, projectId) as ProjectRow | undefined;
|
||||
if (!row) throw new ProjectError("project_not_found");
|
||||
return row;
|
||||
}
|
||||
|
||||
private insertProjectState(input: { canvasState: CanvasState; name: string; projectId: string; stateVersion: number }, now: number) {
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(input.projectId, input.stateVersion, input.name, stableJson(input.canvasState), now);
|
||||
}
|
||||
|
||||
private readProjectState(projectId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT project_id, state_version, name, canvas_json, created_at
|
||||
FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1
|
||||
`).get(projectId) as ProjectStateRow | undefined;
|
||||
if (!row) throw new ProjectError("project_state_invalid");
|
||||
const canvasState: unknown = JSON.parse(row.canvas_json);
|
||||
if (!isProjectEditableState({ canvas_state: canvasState, name: row.name })) throw new ProjectError("project_state_invalid");
|
||||
return { canvasState, name: row.name, stateVersion: row.state_version } as {
|
||||
canvasState: CanvasState; name: string; stateVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
private successfulImageCount(projectId: string) {
|
||||
const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?")
|
||||
.get(projectId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
private latestGenerationStatus(projectId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT status FROM generation_jobs WHERE project_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1
|
||||
`).get(projectId) as { status: string } | undefined;
|
||||
return row?.status ?? null;
|
||||
}
|
||||
|
||||
private tableExists(name: string) {
|
||||
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
}
|
||||
|
||||
private purgeExpiredProjects(ownerId: string, projectId?: string) {
|
||||
const now = this.clock();
|
||||
const transaction = this.database.transaction(() => {
|
||||
const rows = this.database.prepare(`
|
||||
SELECT * FROM projects
|
||||
WHERE owner_id = ? AND status = 'trashed' AND purge_at <= ? AND (? IS NULL OR project_id = ?)
|
||||
ORDER BY purge_at, project_id
|
||||
`).all(ownerId, now, projectId ?? null, projectId ?? null) as ProjectRow[];
|
||||
for (const row of rows) this.transitionToPurged(row, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
}
|
||||
|
||||
private transitionToPurged(project: ProjectRow, now: number) {
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1
|
||||
WHERE project_id = ? AND status = 'trashed'
|
||||
`).run(now, project.project_id);
|
||||
if (changed.changes !== 1) return;
|
||||
this.queueManagedProjectFiles(project.project_id, now);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO project_cleanup_queue (
|
||||
cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error
|
||||
) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL)
|
||||
`).run(
|
||||
randomUUID(), project.project_id, project.owner_id,
|
||||
stableJson(["project_state", "generation", "generated_image", "reference", "location", "latest_export"]), now,
|
||||
);
|
||||
}
|
||||
|
||||
private queueManagedProjectFiles(projectId: string, now: number) {
|
||||
if (!["managed_files", "file_cleanup_queue", "project_asset_refs"].every((table) => this.tableExists(table))) return;
|
||||
const files = this.database.prepare(`
|
||||
SELECT mf.file_id, mf.relative_path, mf.byte_size
|
||||
FROM project_resource_files prf
|
||||
JOIN managed_files mf ON mf.file_id = prf.managed_file_id
|
||||
WHERE prf.project_id = ? AND mf.status = 'committed'
|
||||
ORDER BY mf.file_id
|
||||
`).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
|
||||
for (const file of files) {
|
||||
this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?")
|
||||
.run(`project:${projectId}:${file.file_id}`);
|
||||
const otherProject = this.database.prepare(`
|
||||
SELECT 1 FROM project_resource_files prf
|
||||
JOIN projects p ON p.project_id = prf.project_id
|
||||
WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged'
|
||||
LIMIT 1
|
||||
`).get(file.file_id, projectId);
|
||||
const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id);
|
||||
if (otherProject || otherReference) continue;
|
||||
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'")
|
||||
.run(iso(now), file.file_id);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||
reason, status, created_at, completed_at, last_error
|
||||
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
|
||||
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now));
|
||||
}
|
||||
}
|
||||
|
||||
private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) {
|
||||
const status: ProjectViewStatus = row.status === "trashed"
|
||||
? "trashed"
|
||||
: imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus)
|
||||
? "failed_empty"
|
||||
: "active";
|
||||
return {
|
||||
currentImageId: row.current_image_id,
|
||||
deletedAt: row.deleted_at === null ? null : iso(row.deleted_at),
|
||||
name: row.name,
|
||||
projectId: row.project_id,
|
||||
purgeAt: row.purge_at === null ? null : iso(row.purge_at),
|
||||
ratio: row.ratio,
|
||||
stateVersion: row.state_version,
|
||||
status,
|
||||
successfulImageCount: imageCount,
|
||||
updatedAt: iso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
||||
draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000),
|
||||
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
|
||||
pixel_width INTEGER NOT NULL,
|
||||
pixel_height INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')),
|
||||
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||
current_image_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
purge_at INTEGER,
|
||||
UNIQUE(project_id, ratio),
|
||||
CHECK (
|
||||
(ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR
|
||||
(ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR
|
||||
(ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR
|
||||
(ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920)
|
||||
),
|
||||
CHECK (
|
||||
(status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR
|
||||
(status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR
|
||||
(status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC);
|
||||
DROP TRIGGER IF EXISTS projects_active_insert_limit;
|
||||
CREATE TRIGGER projects_active_insert_limit
|
||||
BEFORE INSERT ON projects
|
||||
WHEN NEW.status = 'active' AND (
|
||||
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
|
||||
) >= ${projectLimit}
|
||||
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
|
||||
CREATE TABLE IF NOT EXISTS project_states (
|
||||
project_id TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
|
||||
canvas_json TEXT NOT NULL CHECK (json_valid(canvas_json)),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (project_id, state_version),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC);
|
||||
CREATE TABLE IF NOT EXISTS project_resource_files (
|
||||
project_id TEXT NOT NULL,
|
||||
managed_file_id TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('derived', 'export', 'generated', 'reference')),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (project_id, managed_file_id),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS project_resource_files_managed ON project_resource_files(managed_file_id, project_id);
|
||||
CREATE TABLE IF NOT EXISTS latest_exports (
|
||||
project_id TEXT NOT NULL,
|
||||
format TEXT NOT NULL CHECK (format IN ('jpg', 'png')),
|
||||
export_id TEXT NOT NULL UNIQUE,
|
||||
managed_file_id TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||
sha256 TEXT NOT NULL CHECK (length(sha256) = 64),
|
||||
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
||||
pixel_width INTEGER NOT NULL CHECK (pixel_width > 0),
|
||||
pixel_height INTEGER NOT NULL CHECK (pixel_height > 0),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (project_id, format),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS project_cleanup_queue (
|
||||
cleanup_id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL UNIQUE,
|
||||
owner_id TEXT NOT NULL,
|
||||
resource_scope_json TEXT NOT NULL CHECK (json_valid(resource_scope_json)),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS project_cleanup_queue_status_created ON project_cleanup_queue(status, created_at, project_id);
|
||||
CREATE TABLE IF NOT EXISTS project_state_idempotency (
|
||||
owner_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||
response_state_version INTEGER NOT NULL CHECK (response_state_version >= 2),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, project_id, idempotency_key),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||
);
|
||||
DROP TRIGGER IF EXISTS projects_active_restore_limit;
|
||||
CREATE TRIGGER projects_active_restore_limit
|
||||
BEFORE UPDATE OF status ON projects
|
||||
WHEN OLD.status <> 'active' AND NEW.status = 'active' AND (
|
||||
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
|
||||
) >= ${projectLimit}
|
||||
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
|
||||
CREATE TABLE IF NOT EXISTS generation_jobs (
|
||||
generation_id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000),
|
||||
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')),
|
||||
model_id TEXT,
|
||||
model_config_version INTEGER,
|
||||
confirmed_credit_cost INTEGER CHECK (confirmed_credit_cost IS NULL OR confirmed_credit_cost > 0),
|
||||
reserved_credits INTEGER NOT NULL DEFAULT 0 CHECK (reserved_credits >= 0),
|
||||
final_credit_state TEXT CHECK (final_credit_state IS NULL OR final_credit_state IN ('committed', 'released')),
|
||||
finished_at INTEGER,
|
||||
error_category TEXT CHECK (error_category IS NULL OR error_category IN (
|
||||
'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled',
|
||||
'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid',
|
||||
'unknown_retryable', 'unknown_non_retryable'
|
||||
)),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(generation_id, project_id),
|
||||
FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE,
|
||||
CHECK (
|
||||
(status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR
|
||||
(status IN ('failed', 'rejected') AND error_category IS NOT NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id);
|
||||
CREATE TABLE IF NOT EXISTS project_images (
|
||||
image_id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
generation_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id);
|
||||
DROP TRIGGER IF EXISTS projects_ratio_immutable;
|
||||
CREATE TRIGGER projects_ratio_immutable
|
||||
BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects
|
||||
WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height
|
||||
BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END;
|
||||
DROP TRIGGER IF EXISTS project_images_history_limit;
|
||||
CREATE TRIGGER project_images_history_limit
|
||||
BEFORE INSERT ON project_images
|
||||
WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit}
|
||||
BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END;
|
||||
`);
|
||||
const missingStates = this.database.prepare(`
|
||||
SELECT p.* FROM projects p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM project_states s WHERE s.project_id = p.project_id)
|
||||
`).all() as ProjectRow[];
|
||||
const insertBackfill = this.database.transaction(() => {
|
||||
for (const project of missingStates) {
|
||||
this.insertProjectState({
|
||||
canvasState: defaultCanvasState(project.ratio, { height: project.pixel_height, width: project.pixel_width }, project.current_image_id),
|
||||
name: project.name,
|
||||
projectId: project.project_id,
|
||||
stateVersion: project.state_version,
|
||||
}, project.updated_at);
|
||||
}
|
||||
});
|
||||
insertBackfill.immediate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
export type RecentAssetKind = "static_sticker" | "text_template";
|
||||
|
||||
export interface RecentAssetItem {
|
||||
asset_id: string;
|
||||
asset_kind: RecentAssetKind;
|
||||
resource_version: string;
|
||||
}
|
||||
|
||||
export class RecentAssetService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly ownsDatabase: boolean;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
|
||||
if (!input.database && !input.databasePath) throw new Error("recent_asset_database_required");
|
||||
this.database = input.database ?? new Database(input.databasePath!);
|
||||
this.ownsDatabase = !input.database;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS recent_assets (
|
||||
user_id TEXT NOT NULL,
|
||||
asset_kind TEXT NOT NULL CHECK (asset_kind IN ('text_template', 'static_sticker')),
|
||||
asset_id TEXT NOT NULL,
|
||||
resource_version TEXT NOT NULL,
|
||||
used_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, asset_kind, asset_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS recent_assets_user_kind_used
|
||||
ON recent_assets (user_id, asset_kind, used_at DESC, asset_id ASC);
|
||||
`);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.ownsDatabase) this.database.close();
|
||||
}
|
||||
|
||||
list(userId: string, assetKind: RecentAssetKind, limit = 12): RecentAssetItem[] {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("recent_asset_limit_invalid");
|
||||
return this.database.prepare(`
|
||||
SELECT asset_id, asset_kind, resource_version
|
||||
FROM recent_assets
|
||||
WHERE user_id = ? AND asset_kind = ?
|
||||
ORDER BY used_at DESC, asset_id ASC
|
||||
LIMIT ?
|
||||
`).all(userId, assetKind, limit) as RecentAssetItem[];
|
||||
}
|
||||
|
||||
recordSuccessfulUse(input: { assetId: string; assetKind: RecentAssetKind; resourceVersion: string; userId: string }) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.assetId)
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.resourceVersion)) {
|
||||
throw new Error("recent_asset_reference_invalid");
|
||||
}
|
||||
this.database.prepare(`
|
||||
INSERT INTO recent_assets (user_id, asset_kind, asset_id, resource_version, used_at)
|
||||
VALUES (@userId, @assetKind, @assetId, @resourceVersion, @usedAt)
|
||||
ON CONFLICT (user_id, asset_kind, asset_id) DO UPDATE SET
|
||||
resource_version = excluded.resource_version,
|
||||
used_at = excluded.used_at
|
||||
`).run({ ...input, usedAt: this.clock() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type RegistrationErrorReason =
|
||||
| "invite_not_found"
|
||||
| "invite_expired"
|
||||
| "invite_disabled"
|
||||
| "invite_exhausted"
|
||||
| "stage_limit_reached"
|
||||
| "email_already_registered"
|
||||
| "challenge_invalid"
|
||||
| "challenge_expired"
|
||||
| "privacy_consent_required"
|
||||
| "privacy_notice_version_invalid"
|
||||
| "profile_invalid"
|
||||
| "idempotency_conflict"
|
||||
| "registration_login_required"
|
||||
| "login_registration_required"
|
||||
| "account_suspended"
|
||||
| "login_admin_required"
|
||||
| "admin_not_allowed"
|
||||
| "resend_too_soon"
|
||||
| "too_many_attempts"
|
||||
| "csrf_invalid"
|
||||
| "deletion_confirmation_invalid"
|
||||
| "session_invalid";
|
||||
|
||||
export type RegistrationErrorCode =
|
||||
| "REGISTRATION_REJECTED"
|
||||
| "REGISTRATION_REQUEST_INVALID"
|
||||
| "IDEMPOTENCY_KEY_CONFLICT"
|
||||
| "AUTH_ENTRY_REJECTED"
|
||||
| "AUTH_RATE_LIMITED"
|
||||
| "AUTH_CSRF_INVALID"
|
||||
| "AUTH_SESSION_INVALID";
|
||||
|
||||
export class RegistrationError extends Error {
|
||||
readonly code: RegistrationErrorCode;
|
||||
readonly httpStatus: 400 | 401 | 403 | 409 | 429;
|
||||
readonly reason: RegistrationErrorReason;
|
||||
|
||||
constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
this.httpStatus = code === "REGISTRATION_REQUEST_INVALID"
|
||||
? 400
|
||||
: code === "AUTH_SESSION_INVALID"
|
||||
? 401
|
||||
: code === "AUTH_CSRF_INVALID"
|
||||
? 403
|
||||
: code === "AUTH_RATE_LIMITED"
|
||||
? 429
|
||||
: 409;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function registrationFieldError(reason: RegistrationErrorReason) {
|
||||
const entries: Record<RegistrationErrorReason, { field: string; message_key: string }> = {
|
||||
challenge_expired: { field: "verification_code", message_key: "auth.challenge.expired" },
|
||||
challenge_invalid: { field: "verification_code", message_key: "auth.challenge.invalid" },
|
||||
email_already_registered: { field: "email", message_key: "auth.email.already_registered" },
|
||||
idempotency_conflict: { field: "idempotency_key", message_key: "request.idempotency_conflict" },
|
||||
invite_disabled: { field: "invite_code", message_key: "auth.invite.disabled" },
|
||||
invite_exhausted: { field: "invite_code", message_key: "auth.invite.exhausted" },
|
||||
invite_expired: { field: "invite_code", message_key: "auth.invite.expired" },
|
||||
invite_not_found: { field: "invite_code", message_key: "auth.invite.not_found" },
|
||||
privacy_consent_required: { field: "privacy_consent_accepted", message_key: "auth.privacy.consent_required" },
|
||||
privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" },
|
||||
profile_invalid: { field: "profile", message_key: "auth.profile.invalid" },
|
||||
stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" },
|
||||
registration_login_required: { field: "email", message_key: "auth.registration.login_required" },
|
||||
login_registration_required: { field: "email", message_key: "auth.login.registration_required" },
|
||||
account_suspended: { field: "email", message_key: "auth.account.suspended" },
|
||||
login_admin_required: { field: "email", message_key: "auth.login.admin_required" },
|
||||
admin_not_allowed: { field: "email", message_key: "admin.auth.not_allowed" },
|
||||
resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" },
|
||||
too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" },
|
||||
csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" },
|
||||
deletion_confirmation_invalid: { field: "confirmation", message_key: "account.deletion.confirmation_invalid" },
|
||||
session_invalid: { field: "session", message_key: "auth.session.invalid" },
|
||||
};
|
||||
return entries[reason];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
export interface RegistrationCodeMessage {
|
||||
challengeId: string;
|
||||
code: string;
|
||||
email: string;
|
||||
purpose: "register" | "login" | "admin_login" | "account_delete";
|
||||
}
|
||||
|
||||
export interface ResendAdapter {
|
||||
sendVerificationCode(message: RegistrationCodeMessage): Promise<void>;
|
||||
}
|
||||
|
||||
export class MockResendAdapter implements ResendAdapter {
|
||||
readonly calls: RegistrationCodeMessage[] = [];
|
||||
|
||||
async sendVerificationCode(message: RegistrationCodeMessage) {
|
||||
this.calls.push({ ...message });
|
||||
}
|
||||
|
||||
readLatestCode(email: string) {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const call = this.calls.findLast((candidate) => candidate.email === normalizedEmail);
|
||||
if (!call) throw new Error("No mock registration message exists for that email.");
|
||||
return call.code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import type { SecureConfigCandidate } from "./registration.js";
|
||||
|
||||
export function readSecureConfigCandidate(path: string): SecureConfigCandidate {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
if (parsed.schema_version !== 1 || !Number.isSafeInteger(parsed.secure_config_revision)) {
|
||||
throw new Error("secure_config_integrity_invalid");
|
||||
}
|
||||
if (!Array.isArray(parsed.admin_allowlist_hashes) || !Array.isArray(parsed.admin_recovery_hashes)) {
|
||||
throw new Error("secure_config_integrity_invalid");
|
||||
}
|
||||
if (parsed.admin_allowlist_hashes.some((value) => typeof value !== "string")
|
||||
|| parsed.admin_recovery_hashes.some((value) => typeof value !== "string")) {
|
||||
throw new Error("secure_config_integrity_invalid");
|
||||
}
|
||||
return {
|
||||
adminAllowlistHashes: parsed.admin_allowlist_hashes as string[],
|
||||
adminRecoveryHashes: parsed.admin_recovery_hashes as string[],
|
||||
secureConfigRevision: parsed.secure_config_revision as number,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap"] as const;
|
||||
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
|
||||
|
||||
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -26,8 +26,10 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
||||
|
||||
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
||||
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
||||
const adminPepperValue = credentials["Dada/P0A/admin/pepper"];
|
||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
||||
if (!configured) throw new Error("API credential client initialization failed.");
|
||||
return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") };
|
||||
}
|
||||
|
||||
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dada/asset-renderer": "workspace:*",
|
||||
"@dada/static-sticker-catalog": "workspace:*",
|
||||
"@dada/shared-contracts": "workspace:*",
|
||||
"@dada/template-registry": "workspace:*",
|
||||
"@vibrant/core": "4.0.4",
|
||||
"@vibrant/quantizer-mmcq": "4.0.4",
|
||||
"fabric": "7.4.0",
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
.settings-page,
|
||||
.settings-loading {
|
||||
min-height: 100vh;
|
||||
color: #111111;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
.settings-loading {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-loading button {
|
||||
min-height: 44px;
|
||||
border: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
min-height: 72px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 5vw;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-brand {
|
||||
color: #111111;
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-header nav {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.settings-header nav a,
|
||||
.settings-header nav span {
|
||||
color: #111111;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-header nav span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
padding: 64px max(5vw, calc((100vw - 1120px) / 2)) 38px;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
}
|
||||
|
||||
.settings-title p,
|
||||
.settings-dialog header p {
|
||||
margin: 0 0 12px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-title h1 {
|
||||
margin: 0;
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.settings-section,
|
||||
.settings-danger {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.4fr);
|
||||
gap: 64px;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 46px 0;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
}
|
||||
|
||||
.settings-section-heading h2,
|
||||
.settings-danger h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.settings-section-heading p,
|
||||
.settings-danger p,
|
||||
.settings-detail {
|
||||
margin: 0;
|
||||
color: #585852;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.settings-form input,
|
||||
.settings-dialog input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #989891;
|
||||
border-radius: 2px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-form label,
|
||||
.settings-dialog label {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-primary,
|
||||
.settings-code-button,
|
||||
.settings-delete-confirm,
|
||||
.settings-danger-button {
|
||||
min-height: 44px;
|
||||
border-radius: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-primary {
|
||||
width: 160px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid #111111;
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.settings-primary:disabled,
|
||||
.settings-code-button:disabled,
|
||||
.settings-delete-confirm:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.settings-definition {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-definition div {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 18px;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #deded9;
|
||||
}
|
||||
|
||||
.settings-definition dt {
|
||||
color: #686862;
|
||||
}
|
||||
|
||||
.settings-definition dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-local {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-local > :not(.settings-section-heading) {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.settings-capacity {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-capacity > div:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-capacity-track {
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-capacity-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.settings-capacity[data-status="warning"] .settings-capacity-track span,
|
||||
.settings-capacity[data-status="critical"] .settings-capacity-track span {
|
||||
background: #e2b500;
|
||||
}
|
||||
|
||||
.settings-capacity[data-status="full"] .settings-capacity-track span,
|
||||
.settings-capacity[data-status="unavailable"] .settings-capacity-track span {
|
||||
background: #c7352b;
|
||||
}
|
||||
|
||||
.settings-fixed-notice {
|
||||
margin: 24px 0 0;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #111111;
|
||||
background: #f2f500;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-detail {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-danger {
|
||||
align-items: center;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.settings-danger-button {
|
||||
width: 160px;
|
||||
justify-self: start;
|
||||
border: 1px solid #a6251d;
|
||||
color: #ffffff;
|
||||
background: #b82f26;
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
margin: 8px 0 0;
|
||||
color: #a6251d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-saved {
|
||||
margin: 8px 0 0;
|
||||
color: #256227;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgb(17 17 17 / 62%);
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
width: min(640px, 100%);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 10px 10px 0 #111111;
|
||||
}
|
||||
|
||||
.settings-dialog header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
}
|
||||
|
||||
.settings-dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.settings-dialog header button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.settings-dialog-body {
|
||||
padding: 26px 28px 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings-dialog-warning {
|
||||
padding: 16px;
|
||||
border-left: 5px solid #b82f26;
|
||||
background: #fff2f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-code-button {
|
||||
width: 180px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-dialog form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-delete-confirm {
|
||||
width: 180px;
|
||||
margin-top: 16px;
|
||||
border: 1px solid #8e2019;
|
||||
color: #ffffff;
|
||||
background: #b82f26;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-header {
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.settings-header nav {
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
padding: 40px 20px 28px;
|
||||
}
|
||||
|
||||
.settings-title h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.settings-section,
|
||||
.settings-danger {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
margin: 0 20px;
|
||||
padding: 34px 0;
|
||||
}
|
||||
|
||||
.settings-local > :not(.settings-section-heading) {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.settings-definition div {
|
||||
grid-template-columns: 88px 1fr;
|
||||
}
|
||||
|
||||
.settings-capacity > div:first-child {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
box-shadow: 5px 5px 0 #111111;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { type FormEvent, useEffect, useId, useState } from "react";
|
||||
|
||||
import "./account-settings.css";
|
||||
|
||||
interface SettingsPayload {
|
||||
account: { email: string; status: "active" };
|
||||
csrf_token: string;
|
||||
local_data: {
|
||||
backup_enabled: false;
|
||||
capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
hard_limit_bytes: number;
|
||||
location: "configured_local_data_root";
|
||||
managed_content_bytes: number;
|
||||
migration_supported: false;
|
||||
};
|
||||
profile: { creator_name: string; social_id: string };
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${Math.max(0, bytes)} B`;
|
||||
}
|
||||
|
||||
function capacityLabel(status: SettingsPayload["local_data"]["capacity_status"]) {
|
||||
if (status === "full") return "本机容量已满";
|
||||
if (status === "unavailable") return "本机数据暂不可写";
|
||||
if (status === "critical") return "本机容量接近上限";
|
||||
if (status === "warning") return "本机容量需要关注";
|
||||
return "本机容量正常";
|
||||
}
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const creatorNameId = useId();
|
||||
const socialId = useId();
|
||||
const confirmationId = useId();
|
||||
const deletionCodeId = useId();
|
||||
const [settings, setSettings] = useState<SettingsPayload>();
|
||||
const [creatorName, setCreatorName] = useState("");
|
||||
const [socialHandle, setSocialHandle] = useState("");
|
||||
const [loadingError, setLoadingError] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [deletionOpen, setDeletionOpen] = useState(false);
|
||||
const [deletionId, setDeletionId] = useState<string>();
|
||||
const [deletionCode, setDeletionCode] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deletionError, setDeletionError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetch("/api/v1/account/settings", { credentials: "same-origin" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error("settings_load_failed");
|
||||
return response.json() as Promise<SettingsPayload>;
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!active) return;
|
||||
setSettings(payload);
|
||||
setCreatorName(payload.profile.creator_name);
|
||||
setSocialHandle(payload.profile.social_id);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setLoadingError(true);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
async function saveProfile(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!settings || saving || !creatorName.trim() || !socialHandle.trim()) return;
|
||||
setSaving(true);
|
||||
setSaveError(false);
|
||||
setSaved(false);
|
||||
try {
|
||||
const response = await fetch("/api/v1/account/settings/profile", {
|
||||
body: JSON.stringify({ creator_name: creatorName, social_id: socialHandle }),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": settings.csrf_token },
|
||||
method: "PUT",
|
||||
});
|
||||
if (!response.ok) throw new Error("profile_save_failed");
|
||||
setSaved(true);
|
||||
} catch {
|
||||
setSaveError(true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDeletionCode() {
|
||||
if (!settings || sendingCode || deleting) return;
|
||||
setSendingCode(true);
|
||||
setDeletionError(false);
|
||||
try {
|
||||
const response = await fetch("/api/v1/account/deletion/send", {
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRF-Token": settings.csrf_token },
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.json() as { deletion_id?: string };
|
||||
if (!response.ok || !body.deletion_id) throw new Error("deletion_code_failed");
|
||||
setDeletionId(body.deletion_id);
|
||||
} catch {
|
||||
setDeletionError(true);
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function completeDeletion(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!settings || !deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting) return;
|
||||
setDeleting(true);
|
||||
setDeletionError(false);
|
||||
try {
|
||||
const response = await fetch("/api/v1/account/deletion/complete", {
|
||||
body: JSON.stringify({ confirmation, deletion_id: deletionId, verification_code: deletionCode }),
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||
"X-CSRF-Token": settings.csrf_token,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("account_deletion_failed");
|
||||
window.location.assign("/");
|
||||
} catch {
|
||||
setDeletionError(true);
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeletion() {
|
||||
if (deleting) return;
|
||||
setDeletionOpen(false);
|
||||
setDeletionId(undefined);
|
||||
setDeletionCode("");
|
||||
setConfirmation("");
|
||||
setDeletionError(false);
|
||||
}
|
||||
|
||||
if (!settings && !loadingError) {
|
||||
return <main className="settings-loading" aria-live="polite">正在读取设置</main>;
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<main className="settings-loading">
|
||||
<p role="alert">设置暂时无法读取。</p>
|
||||
<button onClick={() => window.location.reload()} type="button">重新读取</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const capacityPercent = Math.min(100, Math.round((settings.local_data.managed_content_bytes / settings.local_data.hard_limit_bytes) * 100));
|
||||
|
||||
return (
|
||||
<main className="settings-page">
|
||||
<header className="settings-header">
|
||||
<a className="settings-brand" href="/app">DADA</a>
|
||||
<nav aria-label="账号导航">
|
||||
<a href="/app">创作工作台</a>
|
||||
<span aria-current="page">设置</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="settings-title">
|
||||
<p>ACCOUNT / LOCAL DATA</p>
|
||||
<h1>设置与本机数据</h1>
|
||||
</div>
|
||||
|
||||
<section className="settings-section" aria-labelledby="profile-title">
|
||||
<div className="settings-section-heading">
|
||||
<h2 id="profile-title">个人资料</h2>
|
||||
<p>用于生成内容中的署名信息。</p>
|
||||
</div>
|
||||
<form className="settings-form" onSubmit={saveProfile}>
|
||||
<label htmlFor={creatorNameId}>创作署名</label>
|
||||
<input id={creatorNameId} maxLength={80} onChange={(event) => setCreatorName(event.target.value)} value={creatorName} />
|
||||
<label htmlFor={socialId}>社交 ID</label>
|
||||
<input id={socialId} maxLength={80} onChange={(event) => setSocialHandle(event.target.value)} value={socialHandle} />
|
||||
{saveError ? <p className="settings-error" role="alert">保存失败,当前输入已保留,请重试。</p> : null}
|
||||
{saved ? <p className="settings-saved" role="status">资料已保存。</p> : null}
|
||||
<button className="settings-primary" disabled={saving || !creatorName.trim() || !socialHandle.trim()} type="submit">
|
||||
{saving ? "保存中" : "保存资料"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="settings-section" aria-labelledby="account-title">
|
||||
<div className="settings-section-heading">
|
||||
<h2 id="account-title">账号</h2>
|
||||
<p>邮箱仅用于验证码认证。</p>
|
||||
</div>
|
||||
<dl className="settings-definition">
|
||||
<div><dt>邮箱</dt><dd>{settings.account.email}</dd></div>
|
||||
<div><dt>状态</dt><dd>正常</dd></div>
|
||||
<div><dt>登录方式</dt><dd>邮箱验证码</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="settings-section settings-local" aria-labelledby="local-title">
|
||||
<div className="settings-section-heading">
|
||||
<h2 id="local-title">本机数据</h2>
|
||||
<p>逻辑位置:Dada 配置的本机数据目录</p>
|
||||
</div>
|
||||
<div className="settings-capacity" data-status={settings.local_data.capacity_status}>
|
||||
<div>
|
||||
<strong>{capacityLabel(settings.local_data.capacity_status)}</strong>
|
||||
<span>{formatBytes(settings.local_data.managed_content_bytes)} / {formatBytes(settings.local_data.hard_limit_bytes)}</span>
|
||||
</div>
|
||||
<div className="settings-capacity-track" role="progressbar" aria-label="本机数据容量" aria-valuemax={100} aria-valuemin={0} aria-valuenow={capacityPercent}>
|
||||
<span style={{ width: `${capacityPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-fixed-notice">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。</p>
|
||||
<p className="settings-detail">数据依赖当前 Windows 用户登录和文件系统权限,不提供 Dada 应用层加密或云备份。机器损坏、重装或删除本机数据目录后无法恢复。</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-danger" aria-labelledby="danger-title">
|
||||
<div>
|
||||
<h2 id="danger-title">注销账号</h2>
|
||||
<p>注销会立即删除账号资料、未使用点数和全部作品,并使所有会话失效。</p>
|
||||
</div>
|
||||
<button className="settings-danger-button" onClick={() => setDeletionOpen(true)} type="button">注销账号</button>
|
||||
</section>
|
||||
|
||||
{deletionOpen ? (
|
||||
<div className="settings-dialog-backdrop">
|
||||
<section
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-modal="true"
|
||||
className="settings-dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !deleting) closeDeletion();
|
||||
}}
|
||||
role="dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p>DANGER ZONE</p>
|
||||
<h2 id="delete-dialog-title">确认注销账号</h2>
|
||||
</div>
|
||||
<button aria-label="关闭注销确认" autoFocus disabled={deleting} onClick={closeDeletion} type="button">×</button>
|
||||
</header>
|
||||
<div className="settings-dialog-body">
|
||||
<p className="settings-dialog-warning">账号、资料、项目、图片、坐标、成品和未使用点数将立即删除且不可恢复。</p>
|
||||
<p>匿名生成与点数事件最多保留 180 天,只保留随机主体、时间、模型、结果、错误类别和点数变化;不会保留邮箱或作品内容。</p>
|
||||
<button className="settings-code-button" disabled={sendingCode || deleting || Boolean(deletionId)} onClick={sendDeletionCode} type="button">
|
||||
{sendingCode ? "发送中" : deletionId ? "验证码已发送" : "获取注销验证码"}
|
||||
</button>
|
||||
<form onSubmit={completeDeletion}>
|
||||
<label htmlFor={deletionCodeId}>注销验证码</label>
|
||||
<input disabled={deleting || !deletionId} id={deletionCodeId} inputMode="numeric" maxLength={6} onChange={(event) => setDeletionCode(event.target.value.replace(/\D/g, ""))} value={deletionCode} />
|
||||
<label htmlFor={confirmationId}>确认词</label>
|
||||
<input disabled={deleting} id={confirmationId} onChange={(event) => setConfirmation(event.target.value)} placeholder="输入:注销账号" value={confirmation} />
|
||||
{deletionError ? <p className="settings-error" role="alert">注销请求未完成,请核对验证码后重试。</p> : null}
|
||||
<button className="settings-delete-confirm" disabled={!deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting} type="submit">
|
||||
{deleting ? "注销处理中" : "永久注销"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
:root {
|
||||
color: #121212;
|
||||
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-auth-page {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
display: grid;
|
||||
grid-template-rows: 8px 72px 1fr;
|
||||
}
|
||||
|
||||
.admin-auth-accent {
|
||||
background: #eaff00;
|
||||
}
|
||||
|
||||
.admin-auth-header {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #dedede;
|
||||
display: flex;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.admin-auth-wordmark {
|
||||
color: #111111;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-auth-panel {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
margin: 48px 20px 96px;
|
||||
width: min(440px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
.admin-auth-kicker {
|
||||
color: #606060;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.admin-auth-panel h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.35;
|
||||
margin: 0 0 36px;
|
||||
}
|
||||
|
||||
.admin-auth-panel label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.admin-auth-panel input {
|
||||
border: 1px solid #b9b9b9;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
padding: 0 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-auth-panel input:focus {
|
||||
border-color: #111111;
|
||||
box-shadow: 0 0 0 2px #eaff00;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-auth-send-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) 124px;
|
||||
}
|
||||
|
||||
.admin-auth-panel button {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.admin-auth-panel button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.admin-auth-send {
|
||||
background: #ffffff;
|
||||
border: 1px solid #111111;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.admin-auth-code-field {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.admin-auth-error {
|
||||
border-left: 3px solid #c93333;
|
||||
color: #8d1717;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
margin: 20px 0 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.admin-auth-submit {
|
||||
background: #151515;
|
||||
border: 1px solid #151515;
|
||||
color: #ffffff;
|
||||
margin-top: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-auth-return {
|
||||
color: #363636;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
margin-top: 24px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-auth-header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.admin-auth-panel {
|
||||
align-self: start;
|
||||
margin-top: 72px;
|
||||
}
|
||||
|
||||
.admin-auth-send-row {
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
}
|
||||
|
||||
.admin-auth-send {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useId, useState, type FormEvent } from "react";
|
||||
|
||||
import "./admin-auth.css";
|
||||
|
||||
interface ErrorEnvelopeBody {
|
||||
error?: {
|
||||
details?: { field_errors?: Array<{ message_key?: string }> };
|
||||
};
|
||||
}
|
||||
|
||||
function adminErrorMessage(body: ErrorEnvelopeBody) {
|
||||
const key = body.error?.details?.field_errors?.[0]?.message_key;
|
||||
if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。";
|
||||
if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。";
|
||||
if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。";
|
||||
if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。";
|
||||
return "管理员登录暂时无法完成,请稍后重试。";
|
||||
}
|
||||
|
||||
export function AdminAuthPage() {
|
||||
const emailId = useId();
|
||||
const codeId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [registrationId, setRegistrationId] = useState<string>();
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
async function sendCode() {
|
||||
if (!emailValid || sending || countdown > 0) return;
|
||||
setSending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin-auth/login/send", {
|
||||
body: JSON.stringify({ email }),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string };
|
||||
if (!response.ok || !body.registration_id) {
|
||||
setError(adminErrorMessage(body));
|
||||
return;
|
||||
}
|
||||
setRegistrationId(body.registration_id);
|
||||
setCountdown(60);
|
||||
window.requestAnimationFrame(() => document.getElementById(codeId)?.focus());
|
||||
} catch {
|
||||
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function completeLogin(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin-auth/login/complete", {
|
||||
body: JSON.stringify({ registration_id: registrationId, verification_code: code }),
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.json() as ErrorEnvelopeBody;
|
||||
if (!response.ok) {
|
||||
setError(adminErrorMessage(body));
|
||||
return;
|
||||
}
|
||||
window.location.assign("/admin");
|
||||
} catch {
|
||||
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="admin-auth-page">
|
||||
<div className="admin-auth-accent" aria-hidden="true" />
|
||||
<header className="admin-auth-header">
|
||||
<a className="admin-auth-wordmark" href="/" aria-label="Dada 普通用户登录">DADA</a>
|
||||
</header>
|
||||
<section className="admin-auth-panel">
|
||||
<p className="admin-auth-kicker">ADMIN</p>
|
||||
<h1 id="admin-auth-heading">管理员邮箱验证码登录</h1>
|
||||
<form onSubmit={completeLogin} noValidate>
|
||||
<label htmlFor={emailId}>管理员邮箱</label>
|
||||
<div className="admin-auth-send-row">
|
||||
<input
|
||||
id={emailId}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
setRegistrationId(undefined);
|
||||
setCode("");
|
||||
setCountdown(0);
|
||||
setError(undefined);
|
||||
}}
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
<button
|
||||
aria-label="获取验证码"
|
||||
className="admin-auth-send"
|
||||
disabled={!emailValid || sending || countdown > 0}
|
||||
onClick={sendCode}
|
||||
type="button"
|
||||
>
|
||||
{sending ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||
</button>
|
||||
</div>
|
||||
{registrationId ? (
|
||||
<div className="admin-auth-code-field">
|
||||
<label htmlFor={codeId}>验证码</label>
|
||||
<input
|
||||
id={codeId}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
value={code}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="admin-auth-error" role="alert">{error}</p> : null}
|
||||
<button className="admin-auth-submit" disabled={!registrationId || code.length !== 6 || submitting} type="submit">
|
||||
{submitting ? "登录中" : "登录后台"}
|
||||
</button>
|
||||
</form>
|
||||
<a className="admin-auth-return" href="/">返回普通用户登录</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
.admin-models-page {
|
||||
min-height: 100vh;
|
||||
color: #111111;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
.admin-models-page > main {
|
||||
width: min(1440px, calc(100% - 64px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 80px;
|
||||
}
|
||||
|
||||
.admin-models-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid #999993;
|
||||
}
|
||||
|
||||
.admin-models-heading p {
|
||||
margin: 0 0 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-models-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.admin-models-heading > strong {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-models-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 22px 0;
|
||||
border-block: 1px solid #8c8c86;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.admin-models-summary > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-right: 1px solid #c1c1ba;
|
||||
color: #65655f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-models-summary > span:last-child { border-right: 0; }
|
||||
.admin-models-summary strong { overflow-wrap: anywhere; color: #111111; font-size: 15px; }
|
||||
|
||||
.admin-models-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #8c8c86;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.admin-models-table-wrap table {
|
||||
width: 100%;
|
||||
min-width: 1220px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-models-table-wrap th,
|
||||
.admin-models-table-wrap td {
|
||||
padding: 14px 12px;
|
||||
border-right: 1px solid #d0d0ca;
|
||||
border-bottom: 1px solid #d0d0ca;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-models-table-wrap thead th {
|
||||
background: #e7e7e2;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.admin-models-table-wrap th:first-child { width: 210px; }
|
||||
.admin-models-table-wrap th:nth-child(2),
|
||||
.admin-models-table-wrap th:nth-child(3) { width: 62px; text-align: center; }
|
||||
.admin-models-table-wrap th:nth-child(4) { width: 112px; }
|
||||
.admin-models-table-wrap th:nth-child(5) { width: 96px; }
|
||||
.admin-models-table-wrap th:nth-child(6) { width: 170px; }
|
||||
.admin-models-table-wrap th:nth-child(7) { width: 90px; }
|
||||
.admin-models-table-wrap th:nth-child(8) { width: 180px; }
|
||||
.admin-models-table-wrap th:nth-child(9) { width: 84px; }
|
||||
|
||||
.admin-models-table-wrap tbody th strong,
|
||||
.admin-models-table-wrap tbody th code,
|
||||
.admin-models-table-wrap td small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-models-table-wrap tbody th code,
|
||||
.admin-models-table-wrap td small {
|
||||
margin-top: 5px;
|
||||
color: #65655f;
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-models-table-wrap td:nth-child(2),
|
||||
.admin-models-table-wrap td:nth-child(3) { text-align: center; }
|
||||
|
||||
.admin-models-table-wrap input[type="number"] {
|
||||
width: 76px;
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #777770;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.admin-models-table-wrap input[type="checkbox"],
|
||||
.admin-models-table-wrap input[type="radio"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.model-state { display: inline-block; padding: 4px 6px; border: 1px solid #7d7d77; font-family: Consolas, monospace; }
|
||||
.model-state.is-available,
|
||||
.model-state.is-verified { border-color: #287b45; color: #1f6639; background: #edf8f0; }
|
||||
.model-state.is-unavailable,
|
||||
.model-state.is-unverified,
|
||||
.model-state.is-blocked { border-color: #a66c18; color: #7c4a06; background: #fff7df; }
|
||||
|
||||
.admin-models-actions {
|
||||
display: flex;
|
||||
min-height: 74px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #999993;
|
||||
}
|
||||
|
||||
.admin-models-actions > div:last-child { display: flex; gap: 8px; }
|
||||
.admin-models-actions button,
|
||||
.admin-models-alert button {
|
||||
min-height: 42px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
background: #f2f500;
|
||||
font-weight: 800;
|
||||
}
|
||||
.admin-models-actions button:disabled { color: #777770; background: #dfdfda; cursor: not-allowed; }
|
||||
.admin-models-validation,
|
||||
.admin-models-notice { margin: 0; font-weight: 700; }
|
||||
.admin-models-validation { color: #a52e24; }
|
||||
.admin-models-notice { color: #1f6639; }
|
||||
|
||||
.admin-models-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
border-left: 5px solid #d14a3b;
|
||||
background: #fff1ef;
|
||||
}
|
||||
|
||||
.admin-models-loading { display: grid; gap: 10px; margin-top: 24px; }
|
||||
.admin-models-loading span { display: block; height: 62px; background: #dfdfda; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.admin-models-page > main { width: 100%; padding-right: 16px; padding-left: 16px; }
|
||||
.admin-models-summary { grid-template-columns: 1fr; }
|
||||
.admin-models-summary > span { border-right: 0; border-bottom: 1px solid #c1c1ba; }
|
||||
.admin-models-actions { align-items: stretch; flex-direction: column; }
|
||||
.admin-models-actions > div:last-child { display: grid; }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import "./admin-models.css";
|
||||
|
||||
interface AdminSession { csrf_token: string }
|
||||
interface RuntimeAvailability {
|
||||
available_for_new_jobs: boolean;
|
||||
checked_at: string;
|
||||
reason: string;
|
||||
}
|
||||
interface ModelConfig {
|
||||
config_version: number;
|
||||
contract_evidence_ref: string | null;
|
||||
contract_validation_status: "blocked" | "unverified" | "verified";
|
||||
credit_cost: number;
|
||||
display_name: string;
|
||||
enabled: boolean;
|
||||
error_mapping_profile: Record<string, string>;
|
||||
gateway_account_ref: string;
|
||||
is_default: boolean;
|
||||
model_id: string;
|
||||
prompt_max_length: number;
|
||||
recommendation_priority: number;
|
||||
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
|
||||
route_profile: Record<string, unknown>;
|
||||
runtime_availability: RuntimeAvailability;
|
||||
safety_source: string;
|
||||
supported_ratios: string[];
|
||||
}
|
||||
interface ModelConfiguration {
|
||||
config_set_version: number;
|
||||
configured_default_model_id: string;
|
||||
models: ModelConfig[];
|
||||
recommended_model_id: string | null;
|
||||
}
|
||||
|
||||
async function responseJson<T>(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||
const body = await response.json() as T;
|
||||
return { body, response };
|
||||
}
|
||||
|
||||
function idempotencyKey() {
|
||||
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
function editableModel(model: ModelConfig) {
|
||||
const { config_version: _configVersion, contract_evidence_ref: _evidence, contract_validation_status: _status, runtime_availability: _runtime, ...editable } = model;
|
||||
return editable;
|
||||
}
|
||||
|
||||
function runtimeLabel(runtime: RuntimeAvailability) {
|
||||
return runtime.available_for_new_jobs ? "可用于新任务" : `不可用 · ${runtime.reason}`;
|
||||
}
|
||||
|
||||
export function AdminModelsPage() {
|
||||
const [session, setSession] = useState<AdminSession>();
|
||||
const [configuration, setConfiguration] = useState<ModelConfiguration>();
|
||||
const [draft, setDraft] = useState<ModelConfig[]>();
|
||||
const [loadingFailed, setLoadingFailed] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [conflicted, setConflicted] = useState(false);
|
||||
const priorityRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
async function load() {
|
||||
setLoadingFailed(false);
|
||||
setConflicted(false);
|
||||
try {
|
||||
const [sessionResult, modelResult] = await Promise.all([
|
||||
responseJson<AdminSession>("/api/v1/admin-auth/session"),
|
||||
responseJson<ModelConfiguration>("/api/v1/models"),
|
||||
]);
|
||||
if (!sessionResult.response.ok || !modelResult.response.ok) throw new Error("load_failed");
|
||||
setSession(sessionResult.body);
|
||||
setConfiguration(modelResult.body);
|
||||
setDraft(structuredClone(modelResult.body.models));
|
||||
setNotice("");
|
||||
} catch {
|
||||
setLoadingFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const validation = useMemo(() => {
|
||||
if (!draft) return { valid: false, message: "" };
|
||||
const invalidPriority = draft.find((model) => !Number.isSafeInteger(model.recommendation_priority) || model.recommendation_priority <= 0);
|
||||
if (invalidPriority) return { field: invalidPriority.model_id, kind: "priority" as const, message: "推荐优先级必须是正整数。", valid: false };
|
||||
const duplicatePriority = draft.find((model, index) => draft.findIndex((item) => item.recommendation_priority === model.recommendation_priority) !== index);
|
||||
if (duplicatePriority) return { field: duplicatePriority.model_id, kind: "priority" as const, message: "推荐优先级不能重复。", valid: false };
|
||||
const defaults = draft.filter((model) => model.enabled && model.is_default);
|
||||
if (defaults.length !== 1) return { field: draft.find((model) => model.is_default)?.model_id ?? draft[0]?.model_id, kind: "default" as const, message: "必须指定一个已启用的默认模型。", valid: false };
|
||||
return { message: "", valid: true };
|
||||
}, [draft]);
|
||||
|
||||
function updateModel(modelId: string, change: Partial<ModelConfig>) {
|
||||
setDraft((current) => current?.map((model) => model.model_id === modelId ? { ...model, ...change } : model));
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
function chooseDefault(modelId: string) {
|
||||
setDraft((current) => current?.map((model) => ({ ...model, is_default: model.model_id === modelId })));
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!configuration || !draft || !session || !validation.valid || saving || conflicted) return;
|
||||
setSaving(true);
|
||||
setNotice("");
|
||||
try {
|
||||
const { body, response } = await responseJson<ModelConfiguration & { error?: { code?: string } }>("/api/v1/admin/models/configuration", {
|
||||
body: JSON.stringify({ expected_config_set_version: configuration.config_set_version, models: draft.map(editableModel) }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey(),
|
||||
"X-CSRF-Token": session.csrf_token,
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const code = body.error?.code;
|
||||
if (code === "MODEL_CONFIG_VERSION_CONFLICT") {
|
||||
setConflicted(true);
|
||||
setNotice("配置已被其他管理员更新,请刷新最新配置后重新编辑。");
|
||||
} else if (code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" || code === "MODEL_RECOMMENDATION_PRIORITY_CONFLICT") {
|
||||
const modelId = validation.field ?? draft[0]?.model_id;
|
||||
if (modelId) priorityRefs.current[modelId]?.focus();
|
||||
setNotice("请修正推荐优先级后再保存。");
|
||||
} else if (code === "MODEL_DEFAULT_REPLACEMENT_REQUIRED" || code === "MODEL_DEFAULT_REPLACEMENT_INVALID") {
|
||||
const modelId = validation.field ?? draft[0]?.model_id;
|
||||
if (modelId) defaultRefs.current[modelId]?.focus();
|
||||
setNotice("请指定一个已启用的替代默认模型。");
|
||||
} else setNotice("操作未完成。");
|
||||
return;
|
||||
}
|
||||
setConfiguration(body);
|
||||
setDraft(structuredClone(body.models));
|
||||
setNotice("模型配置已原子保存并记录审计。");
|
||||
} catch {
|
||||
setNotice("操作未完成。");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-models-page">
|
||||
<header className="admin-product-header">
|
||||
<a href="/admin">DADA ADMIN</a>
|
||||
<nav aria-label="后台导航"><a href="/admin/users">用户</a><a aria-current="page" href="/admin/models">模型</a><a href="/admin/audit">审计</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<header className="admin-models-heading">
|
||||
<div><p>MODEL OPERATIONS</p><h1>模型配置</h1></div>
|
||||
{configuration ? <strong>配置集合 v{configuration.config_set_version}</strong> : null}
|
||||
</header>
|
||||
{!configuration && !loadingFailed ? <div aria-label="模型配置加载中" className="admin-models-loading"><span /><span /><span /></div> : null}
|
||||
{loadingFailed ? <p className="admin-models-alert" role="alert">模型配置暂时无法读取。<button onClick={() => void load()} type="button">重试</button></p> : null}
|
||||
{configuration && draft ? (
|
||||
<>
|
||||
<div className="admin-models-summary" aria-label="模型配置摘要">
|
||||
<span>配置默认 <strong>{configuration.configured_default_model_id}</strong></span>
|
||||
<span>当前推荐 <strong>{configuration.recommended_model_id ?? "无"}</strong></span>
|
||||
<span>运行时可用 <strong>{configuration.models.filter((model) => model.runtime_availability.available_for_new_jobs).length} / 3</strong></span>
|
||||
</div>
|
||||
<div className="admin-models-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>模型</th><th>启用</th><th>默认</th><th>推荐优先级</th><th>契约</th><th>运行时可用</th><th>当前推荐</th><th>能力</th><th>配置版本</th></tr></thead>
|
||||
<tbody>
|
||||
{draft.map((model) => (
|
||||
<tr key={model.model_id}>
|
||||
<th scope="row"><strong>{model.display_name}</strong><code>{model.model_id}</code></th>
|
||||
<td><input aria-label={`启用 ${model.display_name}`} checked={model.enabled} onChange={(event) => updateModel(model.model_id, { enabled: event.target.checked })} type="checkbox" /></td>
|
||||
<td><input aria-label={`设为默认 ${model.display_name}`} checked={model.is_default} disabled={!model.enabled} name="configured-default" onChange={() => chooseDefault(model.model_id)} ref={(node) => { defaultRefs.current[model.model_id] = node; }} type="radio" /></td>
|
||||
<td><input aria-label={`${model.display_name} 推荐优先级`} inputMode="numeric" min="1" onChange={(event) => updateModel(model.model_id, { recommendation_priority: Number(event.target.value) })} ref={(node) => { priorityRefs.current[model.model_id] = node; }} type="number" value={model.recommendation_priority} /></td>
|
||||
<td><span className={`model-state is-${model.contract_validation_status}`}>{model.contract_validation_status}</span></td>
|
||||
<td><span className={`model-state ${model.runtime_availability.available_for_new_jobs ? "is-available" : "is-unavailable"}`}>{runtimeLabel(model.runtime_availability)}</span><small>{new Date(model.runtime_availability.checked_at).toLocaleString("zh-CN")}</small></td>
|
||||
<td>{configuration.recommended_model_id === model.model_id ? "是" : "否"}</td>
|
||||
<td><span>{model.credit_cost} 点</span><small>{model.supported_ratios.join(" / ")} · 最多 {model.reference_limits.max_files} 张参考图</small></td>
|
||||
<td>v{model.config_version}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer className="admin-models-actions">
|
||||
<div aria-live="polite">{validation.message ? <p className="admin-models-validation">{validation.message}</p> : null}{notice ? <p className="admin-models-notice" role={conflicted ? "alert" : "status"}>{notice}</p> : null}</div>
|
||||
<div>{conflicted ? <button onClick={() => void load()} type="button">刷新最新配置</button> : null}<button disabled={!validation.valid || saving || conflicted} onClick={() => void save()} type="button">{saving ? "保存中" : "保存完整配置集合"}</button></div>
|
||||
</footer>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
.admin-users-page {
|
||||
min-height: 100vh;
|
||||
color: #111111;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
.admin-product-header {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
padding: 0 max(3vw, 32px);
|
||||
border-bottom: 1px solid #8c8c86;
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.admin-product-header > a,
|
||||
.admin-product-header nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-product-header nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.admin-product-header nav a {
|
||||
min-width: 84px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-product-header nav a[aria-current="page"] {
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.admin-users-page > main {
|
||||
width: min(1180px, calc(100% - 64px));
|
||||
margin: 0 auto;
|
||||
padding: 42px 0 80px;
|
||||
}
|
||||
|
||||
.admin-users-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid #999993;
|
||||
}
|
||||
|
||||
.admin-users-heading p {
|
||||
margin: 0 0 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-users-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.admin-users-heading button,
|
||||
.adjustment-actions button {
|
||||
min-height: 44px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
background: #f2f500;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-credit-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
margin-top: 28px;
|
||||
border-block: 1px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.admin-credit-summary > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 140px;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
padding: 22px;
|
||||
border-right: 1px solid #b3b3ad;
|
||||
}
|
||||
|
||||
.admin-credit-summary > div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.admin-credit-summary span {
|
||||
color: #65655f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-credit-summary strong {
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
.admin-credit-summary code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-users-empty,
|
||||
.admin-users-error,
|
||||
.admin-users-notice {
|
||||
padding: 18px;
|
||||
border-left: 5px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.admin-users-error {
|
||||
border-color: #d14a3b;
|
||||
background: #fff1ef;
|
||||
}
|
||||
|
||||
.admin-users-notice {
|
||||
border-color: #287b45;
|
||||
background: #edf8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-adjustment-overlay {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(17 17 17 / 62%);
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog {
|
||||
width: min(560px, 100%);
|
||||
padding: 28px;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
box-shadow: 10px 10px 0 #f2f500;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog > p {
|
||||
margin: 0 0 6px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog h2 {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.adjustment-direction {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.adjustment-direction label {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #85857f;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.adjustment-direction label + label {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog form > label {
|
||||
margin-top: 6px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog input[type="number"],
|
||||
.credit-adjustment-dialog textarea {
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog textarea {
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.adjustment-preview {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
border-left: 5px solid #111111;
|
||||
background: #e7e7e2;
|
||||
}
|
||||
|
||||
.adjustment-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.adjustment-actions button:last-child {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.adjustment-actions button:disabled {
|
||||
color: #777770;
|
||||
background: #dfdfda;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.is-negative {
|
||||
color: #a52e24;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.admin-users-page > main {
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.admin-credit-summary,
|
||||
.adjustment-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-credit-summary > div {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #b3b3ad;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import "./admin-users.css";
|
||||
|
||||
interface AdminSession {
|
||||
csrf_token: string;
|
||||
}
|
||||
|
||||
interface CreditBalance {
|
||||
available_balance: number;
|
||||
reserved_balance: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
async function readJson<T>(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||
if (response.status === 401) throw new Error("admin_session_invalid");
|
||||
if (!response.ok) throw new Error("request_failed");
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function idempotencyKey() {
|
||||
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const amountId = useId();
|
||||
const reasonId = useId();
|
||||
const userId = new URLSearchParams(window.location.search).get("userId") ?? "";
|
||||
const [session, setSession] = useState<AdminSession>();
|
||||
const [balance, setBalance] = useState<CreditBalance>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [adjusting, setAdjusting] = useState(false);
|
||||
const [direction, setDirection] = useState<"increase" | "decrease">("increase");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [adjustmentId, setAdjustmentId] = useState("");
|
||||
const [adjustmentKey, setAdjustmentKey] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(userId)) return;
|
||||
Promise.all([
|
||||
readJson<AdminSession>("/api/v1/admin-auth/session"),
|
||||
readJson<CreditBalance>(`/api/v1/admin/users/${userId}/credits`),
|
||||
]).then(([nextSession, nextBalance]) => {
|
||||
setSession(nextSession);
|
||||
setBalance(nextBalance);
|
||||
}).catch(() => setFailed(true));
|
||||
}, [userId]);
|
||||
|
||||
const signedAmount = useMemo(() => {
|
||||
const parsed = Number(amount);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) return 0;
|
||||
return direction === "increase" ? parsed : -parsed;
|
||||
}, [amount, direction]);
|
||||
const projected = balance ? balance.available_balance + signedAmount : 0;
|
||||
|
||||
function openAdjustment() {
|
||||
setDirection("increase");
|
||||
setAmount("");
|
||||
setReason("");
|
||||
setAdjustmentId(crypto.randomUUID());
|
||||
setAdjustmentKey(idempotencyKey());
|
||||
setAdjusting(true);
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
async function submitAdjustment(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!session || !balance || !adjustmentId || !adjustmentKey || signedAmount === 0 || !reason.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await readJson<CreditBalance & { adjustment_id: string; status: "adjusted" }>(
|
||||
`/api/v1/admin/users/${userId}/credit-adjustments`,
|
||||
{
|
||||
body: JSON.stringify({ adjustment_id: adjustmentId, amount: signedAmount, reason: reason.trim() }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": adjustmentKey,
|
||||
"X-CSRF-Token": session.csrf_token,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
setBalance({
|
||||
available_balance: result.available_balance,
|
||||
reserved_balance: result.reserved_balance,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
setAdjusting(false);
|
||||
setNotice("点数调整已完成并记录审计");
|
||||
} catch {
|
||||
setNotice("点数调整未完成,请检查输入后重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<header className="admin-product-header">
|
||||
<a href="/admin">DADA ADMIN</a>
|
||||
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users">用户</a><a href="/admin/models">模型</a><a href="/admin/audit">审计</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<header className="admin-users-heading">
|
||||
<div><p>USER OPERATIONS</p><h1>用户点数</h1></div>
|
||||
{balance ? <button onClick={openAdjustment} type="button">调整点数</button> : null}
|
||||
</header>
|
||||
{!userId ? <p className="admin-users-empty">从用户列表选择用户后调整点数。</p> : null}
|
||||
{failed ? <p className="admin-users-error" role="alert">用户点数暂时无法读取。</p> : null}
|
||||
{userId && !balance && !failed ? <p className="admin-users-empty" aria-live="polite">正在读取用户点数</p> : null}
|
||||
{balance ? (
|
||||
<section className="admin-credit-summary" aria-label="用户点数摘要">
|
||||
<div><span>内部用户标识</span><code>{userId}</code></div>
|
||||
<div><span>可用点数</span><strong className={balance.available_balance < 0 ? "is-negative" : undefined}>{balance.available_balance}</strong></div>
|
||||
<div><span>冻结点数</span><strong>{balance.reserved_balance}</strong></div>
|
||||
</section>
|
||||
) : null}
|
||||
{notice ? <p className="admin-users-notice" role="status">{notice}</p> : null}
|
||||
</main>
|
||||
{adjusting && balance ? (
|
||||
<div className="credit-adjustment-overlay">
|
||||
<section aria-labelledby="adjustment-title" aria-modal="true" className="credit-adjustment-dialog" role="dialog">
|
||||
<p>CREDIT ADJUSTMENT</p>
|
||||
<h2 id="adjustment-title">调整点数</h2>
|
||||
<div className="adjustment-direction" role="radiogroup" aria-label="调整方向">
|
||||
<label><input checked={direction === "increase"} name="direction" onChange={() => setDirection("increase")} type="radio" />增加</label>
|
||||
<label><input checked={direction === "decrease"} name="direction" onChange={() => setDirection("decrease")} type="radio" />扣减</label>
|
||||
</div>
|
||||
<form onSubmit={submitAdjustment}>
|
||||
<label htmlFor={amountId}>调整数量</label>
|
||||
<input id={amountId} inputMode="numeric" min="1" onChange={(event) => setAmount(event.target.value.replace(/\D/g, ""))} type="number" value={amount} />
|
||||
<label htmlFor={reasonId}>调整原因</label>
|
||||
<textarea id={reasonId} maxLength={500} onChange={(event) => setReason(event.target.value)} value={reason} />
|
||||
<div className="adjustment-preview">
|
||||
<span>调整前可用点数 {balance.available_balance}</span>
|
||||
<strong>调整后可用点数 {projected}</strong>
|
||||
<span>冻结点数保持 {balance.reserved_balance}</span>
|
||||
</div>
|
||||
<div className="adjustment-actions">
|
||||
<button disabled={signedAmount === 0 || !reason.trim() || submitting} type="submit">{submitting ? "处理中" : "确认调整"}</button>
|
||||
<button disabled={submitting} onClick={() => setAdjusting(false)} type="button">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
|
||||
|
||||
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
|
||||
const previewHalfSize = {
|
||||
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
|
||||
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
|
||||
} as const;
|
||||
|
||||
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#30343b";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const half = previewHalfSize[definition.styleId];
|
||||
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(scale, scale);
|
||||
drawColorCard(context, createColorCardElement(definition, previewPalette, {
|
||||
createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000001",
|
||||
}, 0));
|
||||
}, [definition]);
|
||||
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||
}
|
||||
|
||||
export function ColorCardPanel(props: { canAdd: boolean; hasBackground: boolean; onAdd: (definition: ColorCardDefinition) => void }) {
|
||||
return <section className="editor-provider-panel">
|
||||
<header><h2>色卡</h2><button aria-label="色卡说明" className="editor-info-button" title="色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化" type="button">i</button></header>
|
||||
<p className="editor-provider-note">基于当前原始底图</p>
|
||||
<div className="editor-provider-grid">
|
||||
{P0A_COLOR_CARDS.map((definition) => <button
|
||||
aria-label={`添加色卡 ${definition.cardId} ${definition.displayName}`}
|
||||
disabled={!props.canAdd || !props.hasBackground}
|
||||
key={definition.cardId}
|
||||
onClick={() => props.onAdd(definition)}
|
||||
type="button"
|
||||
><ColorCardPreview definition={definition} /><strong>{definition.cardId}</strong><span>{definition.displayName}</span></button>)}
|
||||
</div>
|
||||
{!props.hasBackground ? <p className="editor-limit" role="status">需要先有原始底图才能提取色卡。</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
.credits-page {
|
||||
width: min(1280px, calc(100% - 64px));
|
||||
margin: 0 auto;
|
||||
padding: 44px 0 80px;
|
||||
}
|
||||
|
||||
.credits-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #9c9c96;
|
||||
}
|
||||
|
||||
.credits-heading p {
|
||||
margin: 0 0 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credits-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.credits-heading time {
|
||||
color: #676761;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.credit-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr minmax(260px, 1.2fr);
|
||||
align-items: stretch;
|
||||
margin: 28px 0;
|
||||
border-block: 1px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credit-summary > div {
|
||||
display: grid;
|
||||
min-height: 142px;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
padding: 22px;
|
||||
border-right: 1px solid #b3b3ad;
|
||||
}
|
||||
|
||||
.credit-summary span,
|
||||
.credit-summary small {
|
||||
color: #65655f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.credit-summary strong {
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.credit-summary > p {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
align-content: center;
|
||||
padding: 22px;
|
||||
background: #f2f500;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.is-negative {
|
||||
color: #a52e24;
|
||||
}
|
||||
|
||||
.is-positive {
|
||||
color: #287b45;
|
||||
}
|
||||
|
||||
.credits-error {
|
||||
padding: 12px 14px;
|
||||
border-left: 5px solid #d14a3b;
|
||||
background: #fff1ef;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-ledger > header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.credit-ledger h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.credit-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.credit-filters label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #5d5d57;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-filters select,
|
||||
.credit-filters input {
|
||||
min-height: 40px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credit-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credit-table-wrap table {
|
||||
width: 100%;
|
||||
min-width: 880px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.credit-table-wrap th,
|
||||
.credit-table-wrap td {
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid #d0d0ca;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.credit-table-wrap th {
|
||||
background: #e7e7e2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.credit-table-wrap td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.credit-table-wrap td small {
|
||||
display: block;
|
||||
max-width: 260px;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
color: #666660;
|
||||
}
|
||||
|
||||
.ledger-empty {
|
||||
display: grid;
|
||||
min-height: 280px;
|
||||
place-items: center;
|
||||
border: 1px dashed #969690;
|
||||
}
|
||||
|
||||
.ledger-more,
|
||||
.ledger-retry {
|
||||
min-height: 44px;
|
||||
margin-top: 14px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.credits-page {
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.credits-heading,
|
||||
.credit-ledger > header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.credit-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.credit-summary > div {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #b3b3ad;
|
||||
}
|
||||
|
||||
.credit-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { LocalOnlyFooter, ProductHeader } from "./project-pages.js";
|
||||
|
||||
import "./credits-page.css";
|
||||
|
||||
type EntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||
|
||||
interface CreditBalance {
|
||||
available_balance: number;
|
||||
reserved_balance: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface LedgerEntry {
|
||||
amount: number;
|
||||
available_after: number;
|
||||
available_before: number;
|
||||
created_at: string;
|
||||
entry_id: string;
|
||||
entry_type: EntryType;
|
||||
model_id: string | null;
|
||||
reason: string | null;
|
||||
reference_id: string | null;
|
||||
reference_type: "registration" | "generation" | "admin_adjustment" | null;
|
||||
reserved_after: number;
|
||||
reserved_before: number;
|
||||
status: "succeeded" | "frozen" | "committed" | "released";
|
||||
}
|
||||
|
||||
interface LedgerResponse {
|
||||
credits: Pick<CreditBalance, "available_balance" | "reserved_balance">;
|
||||
entries: LedgerEntry[];
|
||||
next_cursor: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const entryLabels: Record<EntryType, string> = {
|
||||
admin_adjustment: "管理员调整",
|
||||
generation_commit: "生成结算",
|
||||
generation_release: "生成释放",
|
||||
generation_reserve: "生成冻结",
|
||||
registration_grant: "注册赠点",
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
committed: "已结算",
|
||||
frozen: "已冻结",
|
||||
released: "已释放",
|
||||
succeeded: "已完成",
|
||||
} as const;
|
||||
|
||||
async function readJson<T>(url: string) {
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
throw new Error("session_invalid");
|
||||
}
|
||||
if (!response.ok) throw new Error("request_failed");
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function localTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function CreditsPage() {
|
||||
const [balance, setBalance] = useState<CreditBalance>();
|
||||
const [entries, setEntries] = useState<LedgerEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [eventType, setEventType] = useState<EntryType | "">("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async (cursor?: string) => {
|
||||
const query = new URLSearchParams({ limit: "20" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
if (eventType) query.set("event_type", eventType);
|
||||
if (from) query.set("from", new Date(`${from}T00:00:00`).toISOString());
|
||||
if (to) query.set("to", new Date(`${to}T23:59:59.999`).toISOString());
|
||||
if (cursor) setLoadingMore(true); else setLoading(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
const [nextBalance, ledger] = await Promise.all([
|
||||
readJson<CreditBalance>("/api/v1/me/credits"),
|
||||
readJson<LedgerResponse>(`/api/v1/me/credit-ledger?${query}`),
|
||||
]);
|
||||
setBalance(nextBalance);
|
||||
setEntries((current) => cursor ? [...current, ...ledger.entries] : ledger.entries);
|
||||
setNextCursor(ledger.next_cursor);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== "session_invalid") setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [eventType, from, to]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="product-page">
|
||||
<ProductHeader current="credits" />
|
||||
<main className="credits-page">
|
||||
<header className="credits-heading">
|
||||
<div><p>ACCOUNT CREDIT</p><h1>点数明细</h1></div>
|
||||
{balance ? <time dateTime={balance.updated_at}>更新于 {localTime(balance.updated_at)}</time> : null}
|
||||
</header>
|
||||
{balance ? (
|
||||
<section className="credit-summary" aria-label="点数余额">
|
||||
<div><span>当前可用点数</span><strong className={balance.available_balance < 0 ? "is-negative" : undefined}>{balance.available_balance}</strong></div>
|
||||
<div><span>冻结点数</span><strong>{balance.reserved_balance}</strong><small>已冻结 {balance.reserved_balance} 点</small></div>
|
||||
<p>可用点数不足时,请联系管理员调整点数。</p>
|
||||
</section>
|
||||
) : null}
|
||||
{failed ? <p className="credits-error" role="alert">点数读取失败。{balance ? "当前保留上次余额,提交时仍以服务端实时校验为准。" : "请重新读取。"}</p> : null}
|
||||
<section className="credit-ledger" aria-labelledby="ledger-title">
|
||||
<header>
|
||||
<h2 id="ledger-title">变动记录</h2>
|
||||
<div className="credit-filters">
|
||||
<label>事件类型
|
||||
<select value={eventType} onChange={(event) => setEventType(event.target.value as EntryType | "")}>
|
||||
<option value="">全部事件</option>
|
||||
{Object.entries(entryLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>开始日期<input type="date" value={from} onChange={(event) => setFrom(event.target.value)} /></label>
|
||||
<label>结束日期<input type="date" value={to} onChange={(event) => setTo(event.target.value)} /></label>
|
||||
</div>
|
||||
</header>
|
||||
{loading && entries.length === 0 ? <p className="ledger-empty" aria-live="polite">正在读取点数记录</p> : null}
|
||||
{!loading && entries.length === 0 && !failed ? <p className="ledger-empty">暂无点数变动</p> : null}
|
||||
{entries.length > 0 ? (
|
||||
<div className="credit-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>事件</th><th>状态</th><th>变动</th><th>关联</th><th>余额</th></tr></thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.entry_id}>
|
||||
<td><time dateTime={entry.created_at}>{localTime(entry.created_at)}</time></td>
|
||||
<td><strong>{entryLabels[entry.entry_type]}</strong>{entry.reason ? <small>{entry.reason}</small> : null}</td>
|
||||
<td>{statusLabels[entry.status]}</td>
|
||||
<td className={entry.amount < 0 ? "is-negative" : "is-positive"}>变动 {entry.amount > 0 ? `+${entry.amount}` : entry.amount}</td>
|
||||
<td>{entry.reference_type === "generation" ? "生成任务" : entry.reference_type === "admin_adjustment" ? "管理操作" : "注册"}</td>
|
||||
<td>可用 {entry.available_after} / 冻结 {entry.reserved_after}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{nextCursor ? <button className="ledger-more" disabled={loadingMore} onClick={() => void load(nextCursor)} type="button">{loadingMore ? "加载中" : "加载更多"}</button> : null}
|
||||
{failed ? <button className="ledger-retry" onClick={() => void load()} type="button">重新读取</button> : null}
|
||||
</section>
|
||||
</main>
|
||||
<LocalOnlyFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from "react";
|
||||
|
||||
const focusableSelector = [
|
||||
"a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(",");
|
||||
|
||||
function focusableElements(dialog: HTMLElement) {
|
||||
return [...dialog.querySelectorAll<HTMLElement>(focusableSelector)].filter((element) => !element.hidden && element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function useDialogFocus(onClose: () => void): RefObject<HTMLElement | null> {
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return undefined;
|
||||
const returnTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const initial = dialog.querySelector<HTMLElement>("[data-dialog-initial-focus]") ?? focusableElements(dialog)[0] ?? dialog;
|
||||
initial.focus();
|
||||
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = focusableElements(dialog);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0]!;
|
||||
const last = focusable.at(-1)!;
|
||||
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
if (returnTarget?.isConnected) returnTarget.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dialogRef;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export function DynamicInspector(props: { element: CanvasElement; onCommit: (value: string) => void }) {
|
||||
const [value, setValue] = useState(props.element.formatted_value ?? "");
|
||||
useEffect(() => setValue(props.element.formatted_value ?? ""), [props.element.element_id, props.element.formatted_value]);
|
||||
return <section className="editor-dynamic-inspector">
|
||||
{props.element.template_or_asset_id === "DYN012" ? <p className="editor-font-substitution" role="note">原 DIN_MediumAlternate.otf 缺失,当前明确使用 FONT081 · Lexend Deca 替代。</p> : null}
|
||||
<label>显示文字<textarea aria-label="动态贴纸显示文字" onChange={(event) => setValue(event.target.value)} value={value} /></label>
|
||||
<button className="editor-wide-command" disabled={!value.trim() || value === props.element.formatted_value} onClick={() => props.onCommit(value)} type="button">应用显示文字</button>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
|
||||
|
||||
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type DynamicCategory = "identity" | "location" | "other" | "time";
|
||||
export type DynamicTemplateId = typeof P0A_DYNAMIC_STICKER_IDS[number];
|
||||
|
||||
export interface DynamicStickerDefinition {
|
||||
category: DynamicCategory;
|
||||
displayName: string;
|
||||
requiresLocationConsent: boolean;
|
||||
templateId: DynamicTemplateId;
|
||||
}
|
||||
|
||||
export interface DynamicProviderContext {
|
||||
location?: { formattedValue: string; latitude?: number; longitude?: number };
|
||||
now: Date;
|
||||
profile: { creatorName: string; socialId: string };
|
||||
}
|
||||
|
||||
// Declarative conversion of l_shijian7.prefab. The browser never executes the Android Prefab or Lua.
|
||||
export const DYN012_RENDER_LAYOUT = {
|
||||
background: "transparent",
|
||||
divider: { color: "#FFFFFF", height: 42, width: 7, x: 0, y: 0 },
|
||||
hour: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: -60, y: 0 },
|
||||
meridiem: { color: "#FFFFFF", fontId: "FONT081", fontSize: 20, x: 94, y: -65 },
|
||||
minute: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: 60, y: 0 },
|
||||
} as const;
|
||||
|
||||
export function dyn012DisplayParts(element: CanvasElement) {
|
||||
if (element.type !== "dynamic_sticker" || element.template_or_asset_id !== "DYN012") throw new Error("dyn012_element_required");
|
||||
const formatted = element.formatted_value?.match(/^(\d{1,2}):(\d{2})$/);
|
||||
const hour = formatted?.[1]?.padStart(2, "0") ?? String(element.dynamic_fields?.hour ?? "00").padStart(2, "0");
|
||||
const minute = formatted?.[2] ?? String(element.dynamic_fields?.minute ?? "00").padStart(2, "0");
|
||||
const hourNumber = Number(hour);
|
||||
const minuteNumber = Number(minute);
|
||||
return {
|
||||
hour,
|
||||
meridiem: hourNumber > 12 || (hourNumber === 12 && minuteNumber > 0) ? "PM" : "AM",
|
||||
minute,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const dynamicDefinitions: readonly DynamicStickerDefinition[] = [
|
||||
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
|
||||
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
|
||||
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
|
||||
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
|
||||
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
|
||||
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
|
||||
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
|
||||
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
|
||||
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
|
||||
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
|
||||
] as const;
|
||||
|
||||
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
|
||||
const definition = dynamicDefinitions.find((item) => item.templateId === templateId);
|
||||
if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`);
|
||||
return definition;
|
||||
});
|
||||
|
||||
function twoDigits(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function normalizeSocialId(value: string) {
|
||||
return `@${value.trim().replace(/^@+/, "")}`;
|
||||
}
|
||||
|
||||
function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderContext) {
|
||||
const year = String(context.now.getFullYear());
|
||||
const month = twoDigits(context.now.getMonth() + 1);
|
||||
const day = twoDigits(context.now.getDate());
|
||||
const hour = twoDigits(context.now.getHours());
|
||||
const minute = twoDigits(context.now.getMinutes());
|
||||
if (templateId === "DYN001") return { fields: { title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "输入地点" };
|
||||
if (templateId === "DYN002") return { fields: { city_en: context.location?.formattedValue ?? "LOCATION", title: context.location?.formattedValue ?? "输入地点" }, value: (context.location?.formattedValue ?? "LOCATION").toUpperCase() };
|
||||
if (templateId === "DYN003") return { fields: { city: context.location?.formattedValue ?? "城市", title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "城市 · 输入地点" };
|
||||
if (templateId === "DYN004") {
|
||||
if (context.location?.latitude === undefined || context.location.longitude === undefined) throw new Error("dynamic_location_consent_required");
|
||||
return {
|
||||
fields: { city: context.location.formattedValue, latitude: context.location.latitude, longitude: context.location.longitude },
|
||||
value: `${context.location.formattedValue}\n${context.location.latitude.toFixed(4)}, ${context.location.longitude.toFixed(4)}`,
|
||||
};
|
||||
}
|
||||
if (templateId === "DYN007") {
|
||||
const nickname = normalizeSocialId(context.profile.socialId);
|
||||
return { fields: { nickname }, value: nickname };
|
||||
}
|
||||
if (templateId === "DYN008") return { fields: { hour, minute, month }, value: `${month}月 · ${hour}:${minute}` };
|
||||
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
|
||||
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
|
||||
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
|
||||
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
|
||||
}
|
||||
|
||||
export function createDynamicStickerElement(
|
||||
templateId: DynamicTemplateId,
|
||||
context: DynamicProviderContext,
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
): CanvasElement {
|
||||
if (!P0A_DYNAMIC_STICKERS.some((item) => item.templateId === templateId)) throw new Error("dynamic_template_unavailable");
|
||||
const snapshot = snapshotFor(templateId, context);
|
||||
return {
|
||||
created_at: identity.createdAt,
|
||||
dynamic_fields: snapshot.fields,
|
||||
element_id: identity.elementId,
|
||||
...(templateId === "DYN004" && context.location?.latitude !== undefined && context.location.longitude !== undefined
|
||||
? { coordinates: { latitude: context.location.latitude, longitude: context.location.longitude } }
|
||||
: {}),
|
||||
...(templateId === "DYN012" ? { font_override: "FONT081" } : {}),
|
||||
formatted_value: snapshot.value,
|
||||
opacity: 1,
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resource_version: DYNAMIC_RESOURCE_VERSION,
|
||||
rotation: 0,
|
||||
scale: { x: 1, y: 1 },
|
||||
style_parameters: {
|
||||
provider: "typescript-declarative-v1",
|
||||
...(templateId === "DYN012" ? { font_substitution_disclosure: "DIN_MediumAlternate.otf missing; FONT081 Lexend Deca substitute" } : {}),
|
||||
},
|
||||
template_or_asset_id: templateId,
|
||||
type: "dynamic_sticker",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function overrideDynamicStickerValue(element: CanvasElement, formattedValue: string): CanvasElement {
|
||||
if (element.type !== "dynamic_sticker") throw new Error("dynamic_element_required");
|
||||
const value = formattedValue.trim();
|
||||
if (!value) throw new Error("dynamic_value_required");
|
||||
return {
|
||||
...structuredClone(element),
|
||||
dynamic_fields: { ...structuredClone(element.dynamic_fields ?? {}), display_override: value },
|
||||
formatted_value: value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocationResult {
|
||||
formattedValue: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export class LocationConsentGate {
|
||||
constructor(private readonly adapter: {
|
||||
geolocate: () => Promise<{ latitude: number; longitude: number }>;
|
||||
reverseGeocode: (coordinates: { latitude: number; longitude: number }) => Promise<string>;
|
||||
}) {}
|
||||
|
||||
reject() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async confirm(): Promise<LocationResult> {
|
||||
const coordinates = await this.adapter.geolocate();
|
||||
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|
||||
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
|
||||
throw new Error("dynamic_location_invalid");
|
||||
}
|
||||
const formattedValue = (await this.adapter.reverseGeocode(coordinates)).trim();
|
||||
if (!formattedValue) throw new Error("dynamic_location_unavailable");
|
||||
return { ...coordinates, formattedValue };
|
||||
}
|
||||
}
|
||||
|
||||
export function browserGeolocate() {
|
||||
return new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error("dynamic_location_unsupported"));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude }),
|
||||
() => reject(new Error("dynamic_location_denied")),
|
||||
{ enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { fontOption, type FontOption } from "./text-assets.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type DynamicTextValue =
|
||||
| "city"
|
||||
| "city_en"
|
||||
| "day"
|
||||
| "hour"
|
||||
| "latitude_dms"
|
||||
| "longitude_dms"
|
||||
| "meridiem"
|
||||
| "minute"
|
||||
| "month"
|
||||
| "month_en"
|
||||
| "nickname"
|
||||
| "time"
|
||||
| "title"
|
||||
| "year_short";
|
||||
|
||||
export interface DynamicImageLayer {
|
||||
assetId: string;
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DynamicTextLayer {
|
||||
align: "center" | "left";
|
||||
color: string;
|
||||
fontId: string;
|
||||
fontSize: number;
|
||||
value: DynamicTextValue;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DynamicRenderModel {
|
||||
halfSize: { height: number; width: number };
|
||||
imageLayers: readonly DynamicImageLayer[];
|
||||
sourceCandidateId: string;
|
||||
textLayers: readonly DynamicTextLayer[];
|
||||
}
|
||||
|
||||
export const DYNAMIC_RESOURCE_VERSION = "wp4-dynamic-source-v1";
|
||||
|
||||
const dynamicFont = (fontId: string): FontOption => ({
|
||||
displayName: fontId,
|
||||
fontId,
|
||||
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
|
||||
});
|
||||
|
||||
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
|
||||
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
|
||||
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
|
||||
dynamicFont("53ca6b704728520da50c145eabb2e635"),
|
||||
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
|
||||
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
|
||||
dynamicFont("e4210c9872f0c279b35273f230809821"),
|
||||
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
|
||||
] as const;
|
||||
|
||||
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
|
||||
DYN001: {
|
||||
halfSize: { height: 42, width: 130 },
|
||||
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
|
||||
sourceCandidateId: "l_POI01",
|
||||
textLayers: [{ align: "center", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 21, value: "title", x: 0, y: 0 }],
|
||||
},
|
||||
DYN002: {
|
||||
halfSize: { height: 48, width: 215 },
|
||||
imageLayers: [{ assetId: "DYN002-image29", height: 77, width: 232, x: 94.5, y: 0.5 }],
|
||||
sourceCandidateId: "l_POI02",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 19, value: "title", x: -94.5, y: 10.5 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 10, value: "city_en", x: 0, y: 36 },
|
||||
],
|
||||
},
|
||||
DYN003: {
|
||||
halfSize: { height: 54, width: 215 },
|
||||
imageLayers: [{ assetId: "DYN003-image30", height: 85, width: 235, x: -93.857, y: -23.635 }],
|
||||
sourceCandidateId: "l_POI03",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 18, value: "title", x: -54, y: 9 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 24, value: "city", x: -53, y: -16 },
|
||||
],
|
||||
},
|
||||
DYN004: {
|
||||
halfSize: { height: 48, width: 180 },
|
||||
imageLayers: [{ assetId: "DYN004-image32", height: 76, width: 39, x: -21.067, y: -1 }],
|
||||
sourceCandidateId: "l_POI04",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 26, value: "city", x: -28, y: 0 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "latitude_dms", x: -29.844, y: 36 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "longitude_dms", x: 71.523, y: 36 },
|
||||
],
|
||||
},
|
||||
DYN007: {
|
||||
halfSize: { height: 34, width: 190 },
|
||||
imageLayers: [],
|
||||
sourceCandidateId: "diaoyu",
|
||||
textLayers: [{ align: "center", color: "#295E8B", fontId: "53ca6b704728520da50c145eabb2e635", fontSize: 50, value: "nickname", x: 0, y: 0 }],
|
||||
},
|
||||
DYN008: {
|
||||
halfSize: { height: 180, width: 140 },
|
||||
imageLayers: [{ assetId: "DYN008-backendui0", height: 185, width: 5, x: -125, y: 0 }],
|
||||
sourceCandidateId: "l_shijian2",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 70, value: "month_en", x: -112, y: -32 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 130, value: "time", x: -114.024, y: 97 },
|
||||
],
|
||||
},
|
||||
DYN011: {
|
||||
halfSize: { height: 40, width: 130 },
|
||||
imageLayers: [{ assetId: "DYN011-backendui0", height: 12, width: 8, x: -120, y: -19 }],
|
||||
sourceCandidateId: "l_shijian6",
|
||||
textLayers: [
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "year_short", x: -85, y: 0 },
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "month", x: 0, y: 0 },
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "day", x: 85, y: 0 },
|
||||
],
|
||||
},
|
||||
DYN012: {
|
||||
halfSize: { height: 78, width: 118 },
|
||||
imageLayers: [],
|
||||
sourceCandidateId: "l_shijian7",
|
||||
textLayers: [],
|
||||
},
|
||||
DYN015: {
|
||||
halfSize: { height: 42, width: 300 },
|
||||
imageLayers: [{ assetId: "DYN015-imager2", height: 60, width: 60, x: -140, y: 0 }],
|
||||
sourceCandidateId: "0721userna",
|
||||
textLayers: [{ align: "left", color: "#666666", fontId: "cca5efc0e02fb1bf62349bd68ef30fc1", fontSize: 40, value: "nickname", x: -100, y: 0 }],
|
||||
},
|
||||
DYN016: {
|
||||
halfSize: { height: 48, width: 160 },
|
||||
imageLayers: [{ assetId: "DYN016-image21", height: 59, width: 186, x: -61.842, y: 16.483 }],
|
||||
sourceCandidateId: "l_username00",
|
||||
textLayers: [{ align: "left", color: "#FFFFFF", fontId: "46f8336813e4c48d06a1aef294fdccf6", fontSize: 27, value: "nickname", x: 0, y: 0 }],
|
||||
},
|
||||
};
|
||||
|
||||
export function dynamicFontOptionsFor(templateId: string) {
|
||||
if (templateId === "DYN012") {
|
||||
const replacement = fontOption("FONT081");
|
||||
return replacement ? [replacement] : [];
|
||||
}
|
||||
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
|
||||
if (!model) return [];
|
||||
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
|
||||
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
|
||||
}
|
||||
|
||||
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
|
||||
return `/api/v1/assets/public/${encodeURIComponent(resourceVersion)}/${encodeURIComponent(assetId)}`;
|
||||
}
|
||||
|
||||
function numericField(element: CanvasElement, key: string) {
|
||||
const value = element.dynamic_fields?.[key];
|
||||
return typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
|
||||
function coordinate(value: number, positive: string, negative: string) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
const absolute = Math.abs(value);
|
||||
const degrees = Math.floor(absolute);
|
||||
const minutesFloat = (absolute - degrees) * 60;
|
||||
const minutes = Math.floor(minutesFloat);
|
||||
const seconds = (minutesFloat - minutes) * 60;
|
||||
return `${degrees}°${String(minutes).padStart(2, "0")}'${seconds.toFixed(2).padStart(5, "0")}\"${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
export function dynamicTextValue(element: CanvasElement, value: DynamicTextValue) {
|
||||
const fields = element.dynamic_fields ?? {};
|
||||
const text = (key: string) => String(fields[key] ?? "");
|
||||
const override = fields.display_override;
|
||||
if (typeof override === "string" && (value === "nickname" || value === "title")) return element.formatted_value ?? override;
|
||||
if (value === "time") return `${text("hour")}:${text("minute")}`;
|
||||
if (value === "month_en") {
|
||||
const month = Math.max(1, Math.min(12, numericField(element, "month")));
|
||||
return `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][month - 1]}.`;
|
||||
}
|
||||
if (value === "year_short") return text("year").slice(-2);
|
||||
if (value === "latitude_dms") return coordinate(numericField(element, "latitude"), "N", "S");
|
||||
if (value === "longitude_dms") return coordinate(numericField(element, "longitude"), "E", "W");
|
||||
if (value === "meridiem") {
|
||||
const hour = numericField(element, "hour");
|
||||
const minute = numericField(element, "minute");
|
||||
return hour > 12 || (hour === 12 && minute > 0) ? "PM" : "AM";
|
||||
}
|
||||
return text(value);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { createDynamicStickerElement, P0A_DYNAMIC_STICKERS, type DynamicTemplateId } from "./dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, dynamicImageUrl } from "./dynamic-render-models.js";
|
||||
import { drawDynamicSticker } from "./editor-stage.js";
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
|
||||
const categoryLabels = { identity: "身份", location: "地点", other: "综合", time: "时间" } as const;
|
||||
|
||||
function loadImage(url: string) {
|
||||
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(undefined);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; templateId: DynamicTemplateId }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const statusKey = Object.entries(props.fontStatuses).map(([id, status]) => `${id}:${status}`).join("|");
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const model = DYNAMIC_RENDER_MODELS[props.templateId];
|
||||
const element = createDynamicStickerElement(props.templateId, {
|
||||
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
|
||||
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
|
||||
}, { createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000002" }, 0);
|
||||
void Promise.all(model.imageLayers.map(async (layer) => [layer.assetId, await loadImage(dynamicImageUrl(element.resource_version, layer.assetId))] as const)).then((entries) => {
|
||||
if (!active) return;
|
||||
const canvas = ref.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#30343b";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const scale = Math.min(1, 146 / (model.halfSize.width * 2), 62 / (model.halfSize.height * 2));
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(scale, scale);
|
||||
const images = Object.fromEntries(entries.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
|
||||
drawDynamicSticker(context, element, props.fontStatuses, images);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [props.templateId, statusKey]);
|
||||
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||
}
|
||||
|
||||
export function DynamicStickerPanel(props: { canAdd: boolean; fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; onAdd: (templateId: DynamicTemplateId) => void }) {
|
||||
return <section className="editor-provider-panel">
|
||||
<h2>动态贴纸</h2>
|
||||
<div className="editor-provider-groups">
|
||||
{(["location", "time", "identity", "other"] as const).map((category) => <section key={category}>
|
||||
<h3>{categoryLabels[category]}</h3>
|
||||
<div className="editor-provider-grid">
|
||||
{P0A_DYNAMIC_STICKERS.filter((item) => item.category === category).map((item) => <button
|
||||
aria-label={`添加动态贴纸 ${item.templateId} ${item.displayName}`}
|
||||
disabled={!props.canAdd}
|
||||
key={item.templateId}
|
||||
onClick={() => props.onAdd(item.templateId)}
|
||||
type="button"
|
||||
><DynamicPreview fontStatuses={props.fontStatuses} templateId={item.templateId} /><strong>{item.templateId}</strong><span>{item.displayName}</span>{item.templateId === "DYN012" ? <small>FONT081 替代字体</small> : null}</button>)}
|
||||
</div>
|
||||
</section>)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
|
||||
|
||||
const elementKeys = new Set([
|
||||
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
|
||||
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
|
||||
"template_or_asset_id", "type", "z_index",
|
||||
]);
|
||||
|
||||
export function defaultBackgroundAdjustments(): BackgroundAdjustments {
|
||||
return { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 };
|
||||
}
|
||||
|
||||
function pixelsForRatio(ratio: CanvasState["ratio"]) {
|
||||
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
|
||||
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
|
||||
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
|
||||
: { pixelHeight: 1920, pixelWidth: 1080 };
|
||||
}
|
||||
|
||||
export function createEditorCanvasState(input: { assetId: string | null; ratio: CanvasState["ratio"] }): CanvasState {
|
||||
const pixels = pixelsForRatio(input.ratio);
|
||||
return {
|
||||
background: { adjustments: defaultBackgroundAdjustments(), asset_id: input.assetId },
|
||||
elements: [],
|
||||
pixel_height: pixels.pixelHeight,
|
||||
pixel_width: pixels.pixelWidth,
|
||||
ratio: input.ratio,
|
||||
schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function checked(state: CanvasState): CanvasState {
|
||||
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
export function updateBackgroundAdjustments(state: CanvasState, patch: Partial<BackgroundAdjustments>): CanvasState {
|
||||
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: { ...state.background.adjustments, ...patch } } });
|
||||
}
|
||||
|
||||
export function resetBackgroundAdjustments(state: CanvasState): CanvasState {
|
||||
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: defaultBackgroundAdjustments() } });
|
||||
}
|
||||
|
||||
export function switchBackground(state: CanvasState, assetId: string, palette: readonly string[]): CanvasState {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(assetId)) throw new Error("canvas_background_asset_invalid");
|
||||
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||
const next = structuredClone(state);
|
||||
next.background = { asset_id: assetId, adjustments: defaultBackgroundAdjustments() };
|
||||
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...palette] } : element);
|
||||
return checked(next);
|
||||
}
|
||||
|
||||
export class CanvasEditHistory {
|
||||
private current: CanvasState;
|
||||
private readonly past: CanvasState[] = [];
|
||||
private readonly future: CanvasState[] = [];
|
||||
|
||||
constructor(initial: CanvasState) { this.current = checked(initial); }
|
||||
get value() { return structuredClone(this.current); }
|
||||
get canUndo() { return this.past.length > 0; }
|
||||
get canRedo() { return this.future.length > 0; }
|
||||
|
||||
commit(next: CanvasState) {
|
||||
this.past.push(this.value);
|
||||
this.current = checked(next);
|
||||
this.future.length = 0;
|
||||
}
|
||||
|
||||
undo() {
|
||||
const previous = this.past.pop();
|
||||
if (!previous) return undefined;
|
||||
this.future.push(this.value);
|
||||
this.current = previous;
|
||||
return this.value;
|
||||
}
|
||||
|
||||
redo() {
|
||||
const next = this.future.pop();
|
||||
if (!next) return undefined;
|
||||
this.past.push(this.value);
|
||||
this.current = next;
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeFabricCanvas(input: unknown): CanvasState | undefined {
|
||||
if (!input || typeof input !== "object") return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
if (!value.background || typeof value.background !== "object" || !Array.isArray(value.elements)) return undefined;
|
||||
const elements = value.elements.map((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Object.keys(entry).some((key) => !elementKeys.has(key))) return undefined;
|
||||
return structuredClone(entry);
|
||||
});
|
||||
if (elements.some((entry) => entry === undefined)) return undefined;
|
||||
const candidate = {
|
||||
background: structuredClone(value.background),
|
||||
elements,
|
||||
pixel_height: value.pixel_height,
|
||||
pixel_width: value.pixel_width,
|
||||
ratio: value.ratio,
|
||||
schema_version: value.schema_version,
|
||||
};
|
||||
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
|
||||
}
|
||||
|
||||
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
|
||||
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
|
||||
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export interface CanvasPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CanvasRect extends CanvasPoint {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface CanvasElementIdentity {
|
||||
createdAt: string;
|
||||
elementId: string;
|
||||
}
|
||||
|
||||
export type CanvasLayerCommand = "back" | "backward" | "forward" | "front";
|
||||
|
||||
interface StaticStickerInput {
|
||||
assetId: string;
|
||||
identity: CanvasElementIdentity;
|
||||
opacity?: number;
|
||||
position: CanvasPoint;
|
||||
resourceVersion: string;
|
||||
rotation?: number;
|
||||
scale?: CanvasElement["scale"];
|
||||
style_parameters?: CanvasElement["style_parameters"];
|
||||
z_index?: number;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const snapThreshold = 0.015;
|
||||
const hitHalfExtent = 0.08;
|
||||
|
||||
function styleNumber(element: CanvasElement, key: string, fallback: number) {
|
||||
const value = element.style_parameters?.[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
function textLineUnits(line: string) {
|
||||
return Array.from(line).reduce((total, character) => total + (/^[\x00-\x7F]$/.test(character) ? 0.62 : 1), 0);
|
||||
}
|
||||
|
||||
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
|
||||
if (element.type === "color_card") {
|
||||
const vertical = element.style_id === "style_01" || element.style_id === "style_02";
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, (vertical ? 62 : 175) / state.pixel_width) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, (vertical ? 155 : 50) / state.pixel_height) * element.scale.y,
|
||||
};
|
||||
}
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
const halfWidth = model?.halfSize.width ?? 170;
|
||||
const halfHeight = model?.halfSize.height ?? 62;
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, halfWidth / state.pixel_width) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, halfHeight / state.pixel_height) * element.scale.y,
|
||||
};
|
||||
}
|
||||
if (element.type !== "text_template") {
|
||||
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
|
||||
}
|
||||
const lines = (element.content ?? "").split("\n");
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const letterSpacing = styleNumber(element, "letter_spacing", 1);
|
||||
const lineHeight = styleNumber(element, "line_height", 1.2);
|
||||
const strokeWidth = styleNumber(element, "stroke_width", 0);
|
||||
const longestLine = Math.max(1, ...lines.map((line) => textLineUnits(line)));
|
||||
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
|
||||
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
|
||||
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneState(state: CanvasState) {
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
function requireState(state: CanvasState) {
|
||||
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
||||
return cloneState(state);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function normalizedRotation(value: number) {
|
||||
const normalized = ((value + 180) % 360 + 360) % 360 - 180;
|
||||
return normalized === -180 ? 180 : normalized;
|
||||
}
|
||||
|
||||
function samePoint(left: CanvasPoint | undefined, right: CanvasPoint) {
|
||||
return Boolean(left && Math.abs(left.x - right.x) < 0.001 && Math.abs(left.y - right.y) < 0.001);
|
||||
}
|
||||
|
||||
function reindex(elements: CanvasElement[]) {
|
||||
return elements.map((element, zIndex) => ({ ...element, z_index: zIndex }));
|
||||
}
|
||||
|
||||
export function createStaticStickerElement(input: StaticStickerInput): CanvasElement {
|
||||
return {
|
||||
created_at: input.identity.createdAt,
|
||||
element_id: input.identity.elementId,
|
||||
opacity: input.opacity ?? 1,
|
||||
position: structuredClone(input.position),
|
||||
resource_version: input.resourceVersion,
|
||||
rotation: input.rotation ?? 0,
|
||||
scale: structuredClone(input.scale ?? { x: 1, y: 1 }),
|
||||
style_parameters: structuredClone(input.style_parameters ?? { flip_horizontal: false }),
|
||||
template_or_asset_id: input.assetId,
|
||||
type: "static_sticker",
|
||||
z_index: input.z_index ?? input.zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export class CanvasElementController {
|
||||
private current: CanvasState;
|
||||
private selection: string[] = [];
|
||||
private cyclePoint: CanvasPoint | undefined = undefined;
|
||||
private cycleSignature = "";
|
||||
private cycleIndex = -1;
|
||||
|
||||
constructor(initial: CanvasState) {
|
||||
this.current = requireState(initial);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return cloneState(this.current);
|
||||
}
|
||||
|
||||
get selectedIds() {
|
||||
return [...this.selection];
|
||||
}
|
||||
|
||||
replaceState(next: CanvasState) {
|
||||
this.current = requireState(next);
|
||||
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
||||
this.selection = this.selection.filter((elementId) => existing.has(elementId));
|
||||
this.pointerMoved();
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selection = [];
|
||||
this.pointerMoved();
|
||||
}
|
||||
|
||||
pointerMoved() {
|
||||
this.cyclePoint = undefined;
|
||||
this.cycleSignature = "";
|
||||
this.cycleIndex = -1;
|
||||
}
|
||||
|
||||
selectById(elementId: string, append = false) {
|
||||
if (!this.current.elements.some((element) => element.element_id === elementId)) return this.selectedIds;
|
||||
if (!append) this.selection = [elementId];
|
||||
else if (this.selection.includes(elementId)) this.selection = this.selection.filter((selected) => selected !== elementId);
|
||||
else this.selection = [...this.selection, elementId];
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
selectIds(elementIds: readonly string[]) {
|
||||
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
||||
this.selection = [...new Set(elementIds.filter((elementId) => existing.has(elementId)))];
|
||||
this.pointerMoved();
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
candidatesAt(point: CanvasPoint) {
|
||||
return this.current.elements
|
||||
.filter((element) => {
|
||||
const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element);
|
||||
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
|
||||
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
|
||||
})
|
||||
.sort((left, right) => right.z_index - left.z_index)
|
||||
.map((element) => structuredClone(element));
|
||||
}
|
||||
|
||||
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
|
||||
const candidates = this.candidatesAt(point);
|
||||
if (candidates.length === 0) {
|
||||
if (!options.append) this.selection = [];
|
||||
this.pointerMoved();
|
||||
return this.selectedIds;
|
||||
}
|
||||
if (options.append) {
|
||||
this.pointerMoved();
|
||||
return this.selectById(candidates[0]!.element_id, true);
|
||||
}
|
||||
const signature = candidates.map((candidate) => candidate.element_id).join("|");
|
||||
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
|
||||
else this.cycleIndex = 0;
|
||||
this.cyclePoint = { ...point };
|
||||
this.cycleSignature = signature;
|
||||
this.selection = [candidates[this.cycleIndex]!.element_id];
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
marqueeSelect(rectangle: CanvasRect, append = false) {
|
||||
const minimumX = Math.min(rectangle.x, rectangle.x + rectangle.width);
|
||||
const maximumX = Math.max(rectangle.x, rectangle.x + rectangle.width);
|
||||
const minimumY = Math.min(rectangle.y, rectangle.y + rectangle.height);
|
||||
const maximumY = Math.max(rectangle.y, rectangle.y + rectangle.height);
|
||||
const hits = this.current.elements
|
||||
.filter((element) => element.position.x >= minimumX && element.position.x <= maximumX
|
||||
&& element.position.y >= minimumY && element.position.y <= maximumY)
|
||||
.sort((left, right) => left.z_index - right.z_index)
|
||||
.map((element) => element.element_id);
|
||||
this.selection = append ? [...new Set([...this.selection, ...hits])] : hits;
|
||||
this.pointerMoved();
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
add(element: CanvasElement) {
|
||||
if (this.current.elements.length >= 50) throw new Error("canvas_element_limit_reached");
|
||||
const nextElement = structuredClone(element);
|
||||
nextElement.z_index = this.current.elements.length;
|
||||
this.current = requireState({ ...this.current, elements: [...this.current.elements, nextElement] });
|
||||
this.selection = [nextElement.element_id];
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
replaceElement(element: CanvasElement) {
|
||||
const index = this.current.elements.findIndex((candidate) => candidate.element_id === element.element_id);
|
||||
if (index < 0) throw new Error("canvas_element_not_found");
|
||||
const elements = [...this.current.elements];
|
||||
elements[index] = structuredClone(element);
|
||||
this.current = requireState({ ...this.current, elements });
|
||||
this.selection = [element.element_id];
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
|
||||
const selected = new Set(this.selection);
|
||||
this.current = requireState({
|
||||
...this.current,
|
||||
elements: this.current.elements.map((element) => selected.has(element.element_id) ? mapper(structuredClone(element)) : element),
|
||||
});
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
|
||||
const selected = new Set(this.selection);
|
||||
const primary = this.current.elements.find((element) => selected.has(element.element_id));
|
||||
if (!primary) return { guides: [] as string[], state: this.value };
|
||||
let nextX = primary.position.x + delta.x;
|
||||
let nextY = primary.position.y + delta.y;
|
||||
const guides: string[] = [];
|
||||
const snapAxis = (value: number, axis: "x" | "y") => {
|
||||
const canvasTargets = [0, 0.5, 1];
|
||||
const target = canvasTargets.find((candidate) => Math.abs(value - candidate) <= snapThreshold);
|
||||
if (target !== undefined) {
|
||||
guides.push(target === 0.5 ? `canvas-center-${axis}` : `canvas-edge-${axis}`);
|
||||
return target;
|
||||
}
|
||||
const other = this.current.elements.find((element) => !selected.has(element.element_id) && Math.abs(value - element.position[axis]) <= snapThreshold);
|
||||
if (other) {
|
||||
guides.push(`element-${axis}:${other.element_id}`);
|
||||
return other.position[axis];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
if (options.snap !== false) {
|
||||
nextX = snapAxis(nextX, "x");
|
||||
nextY = snapAxis(nextY, "y");
|
||||
}
|
||||
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
|
||||
const state = this.updateSelected((element) => ({
|
||||
...element,
|
||||
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
|
||||
}));
|
||||
return { guides, state };
|
||||
}
|
||||
|
||||
scaleSelected(factor: number) {
|
||||
if (!Number.isFinite(factor) || factor <= 0) throw new Error("canvas_scale_invalid");
|
||||
return this.updateSelected((element) => ({
|
||||
...element,
|
||||
scale: { x: clamp(element.scale.x * factor, 0.01, 100), y: clamp(element.scale.y * factor, 0.01, 100) },
|
||||
}));
|
||||
}
|
||||
|
||||
rotateSelected(degrees: number) {
|
||||
if (!Number.isFinite(degrees)) throw new Error("canvas_rotation_invalid");
|
||||
return this.updateSelected((element) => ({ ...element, rotation: normalizedRotation(element.rotation + degrees) }));
|
||||
}
|
||||
|
||||
flipSelectedHorizontal() {
|
||||
return this.updateSelected((element) => element.type === "static_sticker" ? {
|
||||
...element,
|
||||
style_parameters: { ...element.style_parameters, flip_horizontal: element.style_parameters?.flip_horizontal !== true },
|
||||
} : element);
|
||||
}
|
||||
|
||||
setSelectedOpacity(opacity: number) {
|
||||
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("canvas_opacity_invalid");
|
||||
return this.updateSelected((element) => element.type === "static_sticker" ? { ...element, opacity } : element);
|
||||
}
|
||||
|
||||
copySelected() {
|
||||
const selected = new Set(this.selection);
|
||||
return this.current.elements.filter((element) => selected.has(element.element_id)).map((element) => structuredClone(element));
|
||||
}
|
||||
|
||||
pasteElements(elements: readonly CanvasElement[], identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
||||
if (this.current.elements.length + elements.length > 50) throw new Error("canvas_element_limit_reached");
|
||||
const pasted = elements.map((source, index) => {
|
||||
const identity = identityFactory(source, index);
|
||||
return {
|
||||
...structuredClone(source),
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
position: { x: clamp(source.position.x + 0.02, 0, 1), y: clamp(source.position.y + 0.02, 0, 1) },
|
||||
z_index: this.current.elements.length + index,
|
||||
};
|
||||
});
|
||||
this.current = requireState({ ...this.current, elements: [...this.current.elements, ...pasted] });
|
||||
this.selection = pasted.map((element) => element.element_id);
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
duplicateSelected(identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
||||
return this.pasteElements(this.copySelected(), identityFactory);
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
const selected = new Set(this.selection);
|
||||
this.current = requireState({ ...this.current, elements: reindex(this.current.elements.filter((element) => !selected.has(element.element_id))) });
|
||||
this.selection = [];
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
changeLayer(command: CanvasLayerCommand) {
|
||||
const selected = new Set(this.selection);
|
||||
const ordered = [...this.current.elements].sort((left, right) => left.z_index - right.z_index);
|
||||
if (command === "front" || command === "back") {
|
||||
const selectedElements = ordered.filter((element) => selected.has(element.element_id));
|
||||
const unselectedElements = ordered.filter((element) => !selected.has(element.element_id));
|
||||
ordered.splice(0, ordered.length, ...(command === "front" ? [...unselectedElements, ...selectedElements] : [...selectedElements, ...unselectedElements]));
|
||||
}
|
||||
else if (command === "forward") {
|
||||
for (let index = ordered.length - 2; index >= 0; index -= 1) {
|
||||
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index + 1]!.element_id)) [ordered[index], ordered[index + 1]] = [ordered[index + 1]!, ordered[index]!];
|
||||
}
|
||||
} else {
|
||||
for (let index = 1; index < ordered.length; index += 1) {
|
||||
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index - 1]!.element_id)) [ordered[index - 1], ordered[index]] = [ordered[index]!, ordered[index - 1]!];
|
||||
}
|
||||
}
|
||||
this.current = requireState({ ...this.current, elements: reindex(ordered) });
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
.editor-page-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||
background: #e8e8e5;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.editor-page-shell :focus-visible {
|
||||
outline: 2px solid #005fcc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.editor-skip-link {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font-weight: 700;
|
||||
transform: translateY(-160%);
|
||||
}
|
||||
.editor-skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.editor-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(160px, 1fr) auto minmax(128px, 160px) auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.editor-toolbar-controls { display: contents; }
|
||||
|
||||
.editor-toolbar button,
|
||||
.editor-back,
|
||||
.editor-inspector button {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
color: #111111;
|
||||
background: #ffffff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.editor-toolbar button:disabled,
|
||||
.editor-inspector button:disabled {
|
||||
border-color: #b9b9b3;
|
||||
color: #62625d;
|
||||
background: #e8e8e5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.editor-back {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-title h1 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-title span,
|
||||
.editor-statusbar,
|
||||
.editor-inspector header p,
|
||||
.editor-confirm p:first-child {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editor-history-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-history-actions button {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.editor-save-status {
|
||||
display: inline-flex;
|
||||
min-width: 128px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-left: 3px solid #16794b;
|
||||
background: #f7f7f5;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-save-status.dirty,
|
||||
.editor-save-status.saving { border-color: #1769aa; }
|
||||
.editor-save-status.failed,
|
||||
.editor-save-status.conflicted { border-color: #c92a24; color: #8f1d14; }
|
||||
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-assets-panel,
|
||||
.editor-inspector {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
background: #f7f7f5;
|
||||
}
|
||||
|
||||
.editor-assets-panel { border-right: 1px solid #b9b9b3; }
|
||||
.editor-inspector { border-left: 1px solid #b9b9b3; padding: 20px; }
|
||||
|
||||
.editor-asset-tabs {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #b9b9b3;
|
||||
}
|
||||
|
||||
.editor-asset-tabs button {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
color: #111111;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-asset-tabs button[aria-current="page"] { border-left-color: #111111; background: #ffffff; }
|
||||
.editor-asset-tabs button:disabled { color: #62625d; cursor: not-allowed; }
|
||||
|
||||
.editor-assets-panel section { padding: 18px 16px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-assets-panel h2 { margin: 0 0 10px; font-size: 16px; }
|
||||
|
||||
.editor-source {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid #b9b9b3;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-source + .editor-source { margin-top: 8px; }
|
||||
.editor-source.active { border: 2px solid #111111; padding: 7px; }
|
||||
.editor-thumb { width: 52px; height: 68px; flex: 0 0 auto; border: 1px solid #b9b9b3; background: #e8e8e5 center / cover no-repeat; }
|
||||
.editor-muted { color: #62625d; font-size: 12px; }
|
||||
|
||||
.editor-sticker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-sticker-virtual-list {
|
||||
height: 280px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.editor-sticker-count { margin: 0 0 8px; color: #62625d; font-size: 12px; }
|
||||
|
||||
.editor-sticker-grid button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
place-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.editor-sticker-grid button:disabled { color: #62625d; background: #e8e8e5; cursor: not-allowed; }
|
||||
.editor-sticker-grid button > span:last-child { overflow-wrap: anywhere; font-size: 11px; }
|
||||
.editor-sticker-preview { display: block; width: 58px; height: 58px; object-fit: contain; border: 1px solid #b9b9b3; background: #30343b; }
|
||||
.editor-limit { margin: 12px 0 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 12px; }
|
||||
|
||||
.editor-workspace {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 40px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
place-items: center;
|
||||
padding: 16px 32px 32px;
|
||||
background: #e8e8e5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-canvas-tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.editor-canvas-tools button {
|
||||
min-width: 76px;
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-canvas-tools button.active { border-color: #005fcc; background: #ffffff; box-shadow: inset 0 -3px #005fcc; }
|
||||
.editor-canvas-tools button:disabled { color: #62625d; background: #d8d8d3; cursor: not-allowed; }
|
||||
|
||||
.editor-canvas-frame {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(100%, 720px);
|
||||
max-height: calc(100vh - 168px);
|
||||
place-items: center;
|
||||
border: 1px solid #62625d;
|
||||
background: #ffffff;
|
||||
box-shadow: 8px 8px 0 #b9b9b3;
|
||||
}
|
||||
|
||||
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
|
||||
.editor-canvas:focus-visible { outline: 2px solid #005fcc; outline-offset: 2px; }
|
||||
|
||||
.editor-candidates {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
width: min(260px, 80%);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
box-shadow: 6px 6px 0 #111111;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
|
||||
.editor-candidates button {
|
||||
min-height: 36px;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #b9b9b3;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor-snap-status {
|
||||
position: absolute;
|
||||
align-self: end;
|
||||
margin-bottom: 8px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #005fcc;
|
||||
background: #ffffff;
|
||||
color: #005fcc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-inspector header { padding-bottom: 16px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-inspector header p { margin: 0 0 4px; }
|
||||
.editor-inspector h2 { margin: 0; font-size: 20px; }
|
||||
.editor-object-id { margin: 12px 0 0; font-family: Consolas, monospace; font-size: 12px; }
|
||||
.editor-inspector > label { display: grid; gap: 6px; margin-top: 18px; font-size: 13px; font-weight: 700; }
|
||||
.editor-inspector select,
|
||||
.editor-inspector input[type="range"] { width: 100%; }
|
||||
.editor-inspector select { min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; font: inherit; }
|
||||
.editor-range > span { display: flex; justify-content: space-between; }
|
||||
.editor-range output { font-family: Consolas, monospace; }
|
||||
.editor-inspector-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 24px; }
|
||||
.editor-primary { background: #f2f400 !important; }
|
||||
|
||||
.editor-object-moves,
|
||||
.editor-object-tools,
|
||||
.editor-layer-tools,
|
||||
.editor-object-actions {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.editor-object-moves { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.editor-object-tools,
|
||||
.editor-layer-tools { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.editor-object-actions { grid-template-columns: 1fr 1fr; padding-top: 18px; border-top: 1px solid #b9b9b3; }
|
||||
.editor-object-moves button,
|
||||
.editor-object-tools button,
|
||||
.editor-layer-tools button,
|
||||
.editor-object-actions button,
|
||||
.editor-wide-command {
|
||||
min-height: 36px;
|
||||
padding: 6px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-wide-command { width: 100%; margin-top: 18px; }
|
||||
.editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; }
|
||||
|
||||
.editor-text-assets { min-width: 0; }
|
||||
.editor-template-search {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
}
|
||||
.editor-template-categories { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin-bottom: 12px; }
|
||||
.editor-template-categories button {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: 4px 2px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
.editor-template-categories button.active { border-color: #005fcc; box-shadow: inset 0 -3px #005fcc; font-weight: 700; }
|
||||
.editor-template-retry { width: 100%; min-height: 36px; margin-bottom: 10px; border: 1px solid #8f1d14; border-radius: 0; background: #ffffff; color: #8f1d14; font: inherit; font-weight: 700; }
|
||||
.editor-template-recent { margin-bottom: 12px; padding: 10px 0; border-block: 1px solid #b9b9b3; }
|
||||
.editor-template-recent h3 { margin: 0 0 8px; font-size: 12px; }
|
||||
.editor-template-recent > div { display: flex; gap: 6px; overflow-x: auto; }
|
||||
.editor-template-recent span { display: grid; flex: 0 0 116px; min-height: 48px; align-content: center; padding: 5px 7px; border-left: 4px solid #f2f400; background: #ffffff; font-size: 10px; }
|
||||
.editor-template-recent strong { font-family: Consolas, monospace; font-size: 10px; }
|
||||
.editor-template-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.editor-template-grid button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 116px;
|
||||
grid-template-rows: 48px 16px minmax(18px, auto) 16px;
|
||||
align-items: center;
|
||||
padding: 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.editor-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
||||
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
||||
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
||||
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
||||
.editor-template-mark.tag { background: #dbeafe; }
|
||||
.editor-template-mark.simple { background: #ffffff; }
|
||||
|
||||
.editor-provider-panel > header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.editor-provider-panel > header h2 { margin: 0; }
|
||||
.editor-info-button { width: 28px; height: 28px; border: 1px solid #111111; border-radius: 50%; background: #ffffff; font: 700 13px Georgia, serif; }
|
||||
.editor-provider-note { margin: 8px 0 12px; color: #62625d; font-size: 11px; }
|
||||
.editor-provider-groups { display: grid; gap: 18px; }
|
||||
.editor-provider-groups section { display: grid; gap: 8px; }
|
||||
.editor-provider-groups h3 { margin: 0; font-size: 12px; }
|
||||
.editor-provider-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.editor-provider-grid button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
grid-template-rows: 48px 16px minmax(18px, auto) auto;
|
||||
align-items: center;
|
||||
padding: 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.editor-provider-grid button:disabled { background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||
.editor-provider-grid strong { font-family: Consolas, monospace; font-size: 10px; }
|
||||
.editor-provider-grid small { color: #8f1d14; font-size: 9px; }
|
||||
.editor-color-card-preview { display: flex; width: 100%; height: 42px; align-items: stretch; border: 1px solid #111111; background: #ffffff; }
|
||||
.editor-color-card-preview i { flex: 1; }
|
||||
.editor-color-card-preview.style_01,
|
||||
.editor-color-card-preview.style_02 { width: 30px; height: 48px; flex-direction: column; justify-self: center; }
|
||||
.editor-dynamic-preview { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #ffffff; font-size: 15px; font-weight: 800; }
|
||||
.editor-dynamic-preview.location { border-left: 6px solid #1769aa; }
|
||||
.editor-dynamic-preview.time { background: #FFE62C; }
|
||||
.editor-dynamic-preview.identity { background: #1769aa; color: #ffffff; }
|
||||
.editor-dynamic-preview.dyn012 { position: relative; display: flex; gap: 7px; justify-content: center; border-color: #111111; background: #30343b; color: #ffffff; font-family: Dada_FONT081, sans-serif; font-size: 17px; font-weight: 400; }
|
||||
.editor-dynamic-preview.dyn012 i { width: 2px; height: 15px; background: #ffffff; }
|
||||
.editor-dynamic-preview.dyn012 small { position: absolute; top: 3px; right: 6px; color: #ffffff; font-size: 6px; }
|
||||
.editor-source-preview-canvas { display: block; width: 100%; height: 72px; background: #30343b; }
|
||||
|
||||
.editor-text-inspector { display: grid; gap: 14px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||
.editor-text-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||
.editor-text-inspector textarea,
|
||||
.editor-text-inspector input[type="number"],
|
||||
.editor-text-inspector select { width: 100%; min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; color: #111111; font: inherit; }
|
||||
.editor-text-inspector textarea { min-height: 96px; resize: vertical; }
|
||||
.editor-text-number { grid-template-columns: minmax(0, 1fr) 68px; }
|
||||
.editor-text-number > span { grid-column: 1 / -1; }
|
||||
.editor-text-number input[type="range"] { align-self: center; }
|
||||
.editor-text-number input[type="number"] { min-width: 0; }
|
||||
.editor-color-fields { display: grid; gap: 8px; margin: 0; padding: 10px; border: 1px solid #b9b9b3; }
|
||||
.editor-color-fields legend { padding-inline: 4px; font-size: 12px; font-weight: 700; }
|
||||
.editor-text-inspector input[type="color"] { width: 100%; height: 40px; padding: 2px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; }
|
||||
.editor-color-swatches { display: flex; gap: 6px; }
|
||||
.editor-color-swatches button { width: 32px; height: 32px; border: 1px solid #111111; border-radius: 0; }
|
||||
.editor-check { display: flex !important; grid-template-columns: 18px 1fr; align-items: center; }
|
||||
.editor-check input { width: 18px; height: 18px; margin: 0; }
|
||||
.editor-dynamic-inspector { display: grid; gap: 10px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||
.editor-dynamic-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||
.editor-dynamic-inspector textarea { min-height: 82px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; resize: vertical; font: inherit; }
|
||||
.editor-dynamic-inspector .editor-wide-command { margin-top: 0; }
|
||||
.editor-font-substitution { margin: 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 11px; }
|
||||
|
||||
.editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
color: #62625d;
|
||||
}
|
||||
|
||||
.editor-notice {
|
||||
position: fixed;
|
||||
z-index: 4;
|
||||
top: 68px;
|
||||
left: 50%;
|
||||
margin: 0;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #1769aa;
|
||||
background: #ffffff;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor-dialog-backdrop { position: fixed; z-index: 5; inset: 0; display: grid; place-items: center; padding: 24px; background: rgb(17 17 17 / 60%); }
|
||||
.editor-confirm { width: min(480px, 100%); padding: 24px; border: 2px solid #111111; background: #ffffff; box-shadow: 8px 8px 0 #111111; }
|
||||
.editor-confirm h2 { margin: 4px 0 12px; font-size: 22px; }
|
||||
.editor-confirm p { margin: 0; }
|
||||
.editor-confirm > div { display: flex; gap: 8px; margin-top: 24px; }
|
||||
.editor-confirm button { min-height: 40px; padding: 8px 14px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 700; }
|
||||
.editor-location-consent label { display: grid; gap: 6px; margin-top: 18px; font-size: 12px; font-weight: 700; }
|
||||
.editor-location-consent input { min-height: 40px; padding: 7px 9px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||
.editor-location-actions { display: grid !important; grid-template-columns: 1fr; }
|
||||
|
||||
.editor-export-dialog {
|
||||
width: min(640px, 100%);
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
overflow: auto;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
box-shadow: 8px 8px 0 #111111;
|
||||
}
|
||||
.editor-export-dialog > header { display: flex; min-height: 82px; align-items: center; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-export-dialog > header p { margin: 0 0 3px; color: #62625d; font-size: 10px; font-weight: 800; }
|
||||
.editor-export-dialog > header h2 { margin: 0; font-size: 22px; }
|
||||
.editor-export-section { padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-export-section h3 { margin: 0 0 10px; font-size: 13px; }
|
||||
.editor-export-segments { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.editor-export-segments button { min-height: 42px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; }
|
||||
.editor-export-segments button + button { border-left: 0; }
|
||||
.editor-export-segments button[aria-pressed="true"] { background: #111111; color: #ffffff; }
|
||||
.editor-export-label { display: flex; align-items: center; justify-content: space-between; }
|
||||
.editor-export-label h3 { margin: 0; }
|
||||
.editor-export-label output { min-width: 36px; font-weight: 800; text-align: right; }
|
||||
.editor-export-quality { display: grid; grid-template-columns: minmax(0, 1fr) 72px; gap: 14px; align-items: center; margin-top: 10px; }
|
||||
.editor-export-quality input[type="number"] { min-width: 0; height: 38px; padding: 5px 7px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||
.editor-export-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 20px; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; font-size: 12px; }
|
||||
.editor-export-summary span { color: #62625d; }
|
||||
.editor-export-summary strong { text-align: right; }
|
||||
.editor-export-pending { display: grid; gap: 10px; margin: 18px 22px 0; padding: 14px; border-left: 4px solid #c92a24; background: #f7f7f4; font-size: 12px; }
|
||||
.editor-export-pending span { display: flex; gap: 9px; align-items: center; }
|
||||
.editor-export-pending input { width: 18px; height: 18px; margin: 0; }
|
||||
.editor-export-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 20px 22px; }
|
||||
.editor-export-dialog > footer button,
|
||||
.editor-export-result button { min-height: 42px; padding: 8px 16px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; }
|
||||
.editor-export-result { display: grid; gap: 12px; padding: 28px 22px 22px; }
|
||||
.editor-export-result > p { margin: 0; padding: 14px; border-left: 4px solid #12805c; background: #effaf5; font-weight: 800; }
|
||||
.editor-export-result.failed > p { border-left-color: #c92a24; background: #fff2f0; color: #8f1d14; }
|
||||
.editor-export-result small { color: #62625d; }
|
||||
.editor-export-result > div { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
|
||||
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.editor-layout { grid-template-columns: 220px minmax(0, 1fr) 280px; }
|
||||
.editor-workspace { padding: 20px; }
|
||||
.editor-toolbar { grid-template-columns: 40px minmax(120px, 1fr) auto minmax(110px, 128px) auto auto; gap: 8px; padding: 0 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||
.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-assets-panel, .editor-inspector { border: 0; }
|
||||
.editor-assets-panel { order: 2; }
|
||||
.editor-inspector { order: 3; }
|
||||
.editor-workspace { min-height: 55vh; order: 1; padding: 16px; }
|
||||
.editor-canvas-frame { width: min(100%, 480px); }
|
||||
.editor-statusbar { flex-wrap: wrap; min-height: 32px; gap: 8px 16px; padding: 8px 12px; }
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
|
||||
|
||||
import { ConflictExportGuard, ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
|
||||
import {
|
||||
CanvasEditHistory,
|
||||
createEditorCanvasState,
|
||||
defaultBackgroundAdjustments,
|
||||
resetBackgroundAdjustments,
|
||||
switchBackground,
|
||||
updateBackgroundAdjustments,
|
||||
} from "./editor-canvas.js";
|
||||
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
|
||||
import { EditorStage } from "./editor-stage.js";
|
||||
import { useDialogFocus } from "./dialog-focus.js";
|
||||
import { ExportDialog } from "./export-dialog.js";
|
||||
import {
|
||||
completePendingTextForExport,
|
||||
composeCanvasExport,
|
||||
downloadExportBlob,
|
||||
exportFilename,
|
||||
resolveExportSettings,
|
||||
saveLatestExport,
|
||||
type ExportFormat,
|
||||
} from "./export-compositor.js";
|
||||
import { runExportFlow, type ExportFlowStatus } from "./export-flow.js";
|
||||
import { TextInspector } from "./text-inspector.js";
|
||||
import { createBrowserArchivedFontLoader, type ArchivedFontLoader, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import {
|
||||
fontIdForTextElement,
|
||||
fontOption,
|
||||
P0A_TEXT_TEMPLATES,
|
||||
TextEditSession,
|
||||
createTextTemplateElement,
|
||||
type TextStylePatch,
|
||||
type TextTemplateCategory,
|
||||
type TextTemplateDefinition,
|
||||
} from "./text-assets.js";
|
||||
import { TextTemplatePanel } from "./text-template-panel.js";
|
||||
import { ColorCardPanel } from "./color-card-panel.js";
|
||||
import { DynamicInspector } from "./dynamic-inspector.js";
|
||||
import { DynamicStickerPanel } from "./dynamic-sticker-panel.js";
|
||||
import {
|
||||
LocationConsentGate,
|
||||
P0A_DYNAMIC_STICKERS,
|
||||
browserGeolocate,
|
||||
createDynamicStickerElement,
|
||||
overrideDynamicStickerValue,
|
||||
type DynamicTemplateId,
|
||||
} from "./dynamic-provider.js";
|
||||
import { dynamicFontOptionsFor } from "./dynamic-render-models.js";
|
||||
import { P0A_STATIC_STICKER_CATALOG, P0A_STATIC_STICKER_COUNT, stickerWindow } from "./static-sticker-catalog.js";
|
||||
import {
|
||||
createColorCardElement,
|
||||
extractPaletteFromImage,
|
||||
type ColorCardDefinition,
|
||||
} from "./palette-provider.js";
|
||||
|
||||
import "./editor-page.css";
|
||||
|
||||
type Ratio = CanvasState["ratio"];
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
type EditorAssetPanel = "background" | "color" | "dynamic" | "history" | "stickers" | "text";
|
||||
|
||||
interface TextEditState {
|
||||
draft: CanvasElement;
|
||||
elementId: string;
|
||||
originalTemplateId: string;
|
||||
}
|
||||
|
||||
interface EditorSession {
|
||||
csrf_token: string;
|
||||
user: { creator_name: string; social_id: string; user_id: string };
|
||||
}
|
||||
|
||||
interface LocationDialogState {
|
||||
error?: string;
|
||||
manualValue: string;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
interface EditorExportResult {
|
||||
blob: Blob | null;
|
||||
format: ExportFormat;
|
||||
quality?: number;
|
||||
status: ExportFlowStatus;
|
||||
}
|
||||
|
||||
interface EditorProject {
|
||||
canvas_state?: CanvasState;
|
||||
created_at: string;
|
||||
current_image_id: string | null;
|
||||
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
|
||||
name: string;
|
||||
pixel_height?: number;
|
||||
pixel_width?: number;
|
||||
project_id: string;
|
||||
ratio: Ratio;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
async function readEditorJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
throw new Error("session_invalid");
|
||||
}
|
||||
if (!response.ok) throw new Error("editor_request_failed");
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function newElementIdentity(): CanvasElementIdentity {
|
||||
return { createdAt: new Date().toISOString(), elementId: crypto.randomUUID() };
|
||||
}
|
||||
|
||||
function saveStatusLabel(status: ProjectSaveStatus) {
|
||||
return { conflicted: "版本冲突", dirty: "有未保存修改", failed: "保存失败", saved: "已保存", saving: "正在保存" }[status];
|
||||
}
|
||||
|
||||
function EditorDialog(props: { children: ReactNode; className?: string; labelledBy: string; onClose: () => void }) {
|
||||
const dialogRef = useDialogFocus(props.onClose);
|
||||
return <div className="editor-dialog-backdrop"><section aria-labelledby={props.labelledBy} aria-modal="true" className={props.className ?? "editor-confirm"} ref={dialogRef} role="dialog" tabIndex={-1}>{props.children}</section></div>;
|
||||
}
|
||||
|
||||
export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const [session, setSession] = useState<EditorSession>();
|
||||
const [project, setProject] = useState<EditorProject>();
|
||||
const [canvasState, setCanvasState] = useState<CanvasState>();
|
||||
const [draftAdjustments, setDraftAdjustments] = useState(defaultBackgroundAdjustments);
|
||||
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
|
||||
const [pendingBackground, setPendingBackground] = useState<string>();
|
||||
const [notice, setNotice] = useState("");
|
||||
const [activePanel, setActivePanel] = useState<EditorAssetPanel>("background");
|
||||
const [stickerScrollTop, setStickerScrollTop] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [guides, setGuides] = useState<string[]>([]);
|
||||
const [multiMode, setMultiMode] = useState(false);
|
||||
const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>();
|
||||
const [templateCategory, setTemplateCategory] = useState<TextTemplateCategory>();
|
||||
const [templateQuery, setTemplateQuery] = useState("");
|
||||
const [recentTextIds, setRecentTextIds] = useState<string[]>([]);
|
||||
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
|
||||
const [textEdit, setTextEdit] = useState<TextEditState>();
|
||||
const [locationDialog, setLocationDialog] = useState<LocationDialogState>();
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exportBusy, setExportBusy] = useState(false);
|
||||
const [exportResult, setExportResult] = useState<EditorExportResult>();
|
||||
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([
|
||||
readEditorJson<EditorSession>("/api/v1/auth/session"),
|
||||
readEditorJson<EditorProject>(`/api/v1/projects/${projectId}`),
|
||||
]).then(([nextSession, nextProject]) => {
|
||||
if (!active) return;
|
||||
const initial = nextProject.canvas_state ?? createEditorCanvasState({ assetId: nextProject.current_image_id, ratio: nextProject.ratio });
|
||||
setSession(nextSession);
|
||||
setProject(nextProject);
|
||||
setCanvasState(initial);
|
||||
setDraftAdjustments(initial.background.adjustments);
|
||||
historyRef.current = new CanvasEditHistory(initial);
|
||||
elementControllerRef.current = new CanvasElementController(initial);
|
||||
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
let active = true;
|
||||
readEditorJson<{ items: Array<{ asset_id: string }> }>("/api/v1/assets/recent?asset_kind=text_template")
|
||||
.then((response) => { if (active) setRecentTextIds(response.items.map((item) => item.asset_id)); })
|
||||
.catch(() => { if (active) setRecentTextIds([]); });
|
||||
return () => { active = false; };
|
||||
}, [session?.user.user_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !session || !canvasState) return undefined;
|
||||
const queue = new ProjectAutoSaveQueue({
|
||||
initialState: { canvas_state: canvasState, name: project.name },
|
||||
initialVersion: project.state_version,
|
||||
onStatus: setSaveStatus,
|
||||
onSaved: (_snapshot, stateVersion) => {
|
||||
setProject((current) => current ? { ...current, state_version: stateVersion } : current);
|
||||
},
|
||||
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
|
||||
const response = await fetch(`/api/v1/projects/${projectId}/state`, {
|
||||
body: JSON.stringify(snapshot),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "Idempotency-Key": operationId, "If-Match": String(stateVersion), "X-CSRF-Token": session.csrf_token },
|
||||
method: "PUT",
|
||||
});
|
||||
if (response.status === 412) throw new Error("project_state_conflict");
|
||||
if (!response.ok) throw new Error("project_save_failed");
|
||||
const saved = await response.json() as { state_version: number };
|
||||
return { stateVersion: saved.state_version };
|
||||
},
|
||||
});
|
||||
queueRef.current = queue;
|
||||
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
|
||||
}, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasState) return;
|
||||
for (const element of canvasState.elements) {
|
||||
const options = element.type === "text_template"
|
||||
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
|
||||
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
|
||||
for (const option of options) void ensureFont(option.fontId, option.url);
|
||||
}
|
||||
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePanel !== "dynamic") return;
|
||||
const options = P0A_DYNAMIC_STICKERS.flatMap((definition) => dynamicFontOptionsFor(definition.templateId));
|
||||
for (const option of options) void ensureFont(option.fontId, option.url);
|
||||
}, [activePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (candidateMenu) candidateMenuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
|
||||
}, [candidateMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasState || selectedIds.length !== 1) {
|
||||
setTextEdit(undefined);
|
||||
return;
|
||||
}
|
||||
const element = canvasState.elements.find((candidate) => candidate.element_id === selectedIds[0]);
|
||||
if (!element || element.type !== "text_template") {
|
||||
setTextEdit(undefined);
|
||||
return;
|
||||
}
|
||||
setTextEdit((current) => current?.elementId === element.element_id ? current : {
|
||||
draft: structuredClone(element), elementId: element.element_id, originalTemplateId: element.template_or_asset_id,
|
||||
});
|
||||
}, [canvasState, selectedIds.join("|")]);
|
||||
|
||||
async function ensureFont(fontId: string, url: string, retry = false) {
|
||||
const current = fontStatuses[fontId];
|
||||
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
||||
const loader = fontLoaderRef.current ?? createBrowserArchivedFontLoader();
|
||||
fontLoaderRef.current = loader;
|
||||
setFontStatuses((statuses) => ({ ...statuses, [fontId]: "loading" }));
|
||||
const status = retry ? await loader.retry({ fontId, url }) : await loader.ensure({ fontId, url });
|
||||
setFontStatuses((statuses) => ({ ...statuses, [fontId]: status }));
|
||||
return status;
|
||||
}
|
||||
|
||||
async function retryTextFonts() {
|
||||
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
|
||||
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
||||
}
|
||||
|
||||
function commitCanvas(next: CanvasState) {
|
||||
if (!project || saveStatus === "conflicted") return;
|
||||
historyRef.current?.commit(next);
|
||||
elementControllerRef.current?.replaceState(next);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
setTextEdit(undefined);
|
||||
}
|
||||
|
||||
function applyPreview() {
|
||||
if (!canvasState) return;
|
||||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||
setNotice("底图调整已提交");
|
||||
}
|
||||
|
||||
function undo() {
|
||||
const previous = historyRef.current?.undo();
|
||||
if (previous) {
|
||||
elementControllerRef.current?.replaceState(previous);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(previous);
|
||||
setDraftAdjustments(previous.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
|
||||
setTextEdit(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function redo() {
|
||||
const next = historyRef.current?.redo();
|
||||
if (next) {
|
||||
elementControllerRef.current?.replaceState(next);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
setTextEdit(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function paletteForAsset(assetId: string) {
|
||||
const image = new Image();
|
||||
const loaded = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("palette_image_unavailable"));
|
||||
});
|
||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
|
||||
return extractPaletteFromImage(await loaded);
|
||||
}
|
||||
|
||||
async function confirmBackground() {
|
||||
if (!canvasState || !pendingBackground) return;
|
||||
try {
|
||||
const palette = await paletteForAsset(pendingBackground);
|
||||
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||
setPendingBackground(undefined);
|
||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
} catch {
|
||||
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||
}
|
||||
}
|
||||
|
||||
function controllerForCurrent() {
|
||||
const controller = elementControllerRef.current;
|
||||
if (!controller) return undefined;
|
||||
if (controller.selectedIds.length !== selectedIds.length || controller.selectedIds.some((elementId, index) => elementId !== selectedIds[index])) controller.selectIds(selectedIds);
|
||||
return controller;
|
||||
}
|
||||
|
||||
function commitElementOperation(controller: CanvasElementController, message: string) {
|
||||
const next = controller.value;
|
||||
setSelectedIds(controller.selectedIds);
|
||||
commitCanvas(next);
|
||||
setGuides([]);
|
||||
setCandidateMenu(undefined);
|
||||
setNotice(message);
|
||||
}
|
||||
|
||||
function addSticker(assetId: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || !canvasState) return;
|
||||
try {
|
||||
controller.add(createStaticStickerElement({
|
||||
assetId,
|
||||
identity: newElementIdentity(),
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resourceVersion: "fixture-v1",
|
||||
zIndex: canvasState.elements.length,
|
||||
}));
|
||||
commitElementOperation(controller, "贴纸已加入画布");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
async function addColorCard(definition: ColorCardDefinition) {
|
||||
const assetId = canvasState?.background.asset_id;
|
||||
const controller = controllerForCurrent();
|
||||
if (!assetId || !canvasState || !controller) return;
|
||||
try {
|
||||
const palette = await paletteForAsset(assetId);
|
||||
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("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
async function addDynamicSticker(templateId: DynamicTemplateId, location?: { formattedValue: string; latitude?: number; longitude?: number }) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!canvasState || !controller || !session) return;
|
||||
if (templateId === "DYN012") {
|
||||
const font = fontOption("FONT081");
|
||||
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
controller.add(createDynamicStickerElement(templateId, {
|
||||
...(location ? { location } : {}),
|
||||
now: new Date(),
|
||||
profile: { creatorName: session.user.creator_name, socialId: session.user.social_id },
|
||||
}, newElementIdentity(), canvasState.elements.length));
|
||||
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||
setLocationDialog(undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("动态贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
function chooseDynamicSticker(templateId: DynamicTemplateId) {
|
||||
if (templateId === "DYN004") {
|
||||
setLocationDialog({ manualValue: "", pending: false });
|
||||
return;
|
||||
}
|
||||
void addDynamicSticker(templateId);
|
||||
}
|
||||
|
||||
async function confirmAutomaticLocation() {
|
||||
if (!session) return;
|
||||
setLocationDialog((current) => {
|
||||
if (!current) return current;
|
||||
const { error: _error, ...rest } = current;
|
||||
return { ...rest, pending: true };
|
||||
});
|
||||
const gate = new LocationConsentGate({
|
||||
geolocate: browserGeolocate,
|
||||
reverseGeocode: async (coordinates) => {
|
||||
const response = await fetch("/api/v1/location/reverse-geocode", {
|
||||
body: JSON.stringify(coordinates),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("dynamic_location_service_unavailable");
|
||||
const payload = await response.json() as { formatted_value: string };
|
||||
return payload.formatted_value;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await gate.confirm();
|
||||
await addDynamicSticker("DYN004", result);
|
||||
} catch {
|
||||
setLocationDialog((current) => current ? { ...current, error: "自动定位不可用,请改用手动地点贴纸。", pending: false } : current);
|
||||
}
|
||||
}
|
||||
|
||||
function commitDynamicOverride(element: CanvasElement, value: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
try {
|
||||
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||
} catch {
|
||||
setNotice("动态贴纸显示文字不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
|
||||
if (!session) return;
|
||||
try {
|
||||
const response = await fetch("/api/v1/assets/recent", {
|
||||
body: JSON.stringify({ asset_id: templateId, asset_kind: "text_template", resource_version: resourceVersion }),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) return;
|
||||
setRecentTextIds((current) => [templateId, ...current.filter((id) => id !== templateId)].slice(0, 12));
|
||||
} catch {
|
||||
// Recent-use metadata does not roll back an otherwise successful canvas edit.
|
||||
}
|
||||
}
|
||||
|
||||
async function addTextTemplate(template: TextTemplateDefinition) {
|
||||
if (!template.fontUrl || !canvasState) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
try {
|
||||
controller.add(createTextTemplateElement(template, newElementIdentity(), canvasState.elements.length));
|
||||
commitElementOperation(controller, "文字模板已加入画布");
|
||||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("文字模板未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
function updateTextDraft(action: (session: TextEditSession) => void) {
|
||||
setTextEdit((current) => {
|
||||
if (!current) return current;
|
||||
try {
|
||||
const edit = new TextEditSession(current.draft, P0A_TEXT_TEMPLATES);
|
||||
action(edit);
|
||||
return { ...current, draft: edit.value };
|
||||
} catch {
|
||||
setNotice("文字参数不在允许范围内");
|
||||
return current;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function changeTextTemplate(templateId: string) {
|
||||
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||||
if (!template?.fontUrl) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||
}
|
||||
|
||||
async function changeTextFont(fontId: string | null) {
|
||||
if (!fontId) {
|
||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: null }));
|
||||
return;
|
||||
}
|
||||
const option = fontOption(fontId);
|
||||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||
}
|
||||
|
||||
function completeTextEdit() {
|
||||
if (!textEdit) return;
|
||||
try {
|
||||
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||||
const complete = edit.complete();
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
controller.replaceElement(complete);
|
||||
commitElementOperation(controller, "文字编辑已完成");
|
||||
if (complete.template_or_asset_id !== textEdit.originalTemplateId) {
|
||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||||
else setNotice("文字编辑未能完成");
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTextEdit() {
|
||||
if (canvasState && textEdit) {
|
||||
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("已取消未提交的文字修改");
|
||||
}
|
||||
|
||||
function pendingTextDraft() {
|
||||
if (!canvasState || !textEdit) return undefined;
|
||||
const committed = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||||
return committed && JSON.stringify(committed) !== JSON.stringify(textEdit.draft) ? textEdit : undefined;
|
||||
}
|
||||
|
||||
function openExportDialog() {
|
||||
setExportResult(undefined);
|
||||
setExportOpen(true);
|
||||
}
|
||||
|
||||
async function executeExport(options: { format: ExportFormat; quality?: number }, reuseBlob?: Blob) {
|
||||
if (!canvasState || !project || !session) return;
|
||||
setExportBusy(true);
|
||||
try {
|
||||
let exportState = canvasState;
|
||||
const pending = reuseBlob ? undefined : pendingTextDraft();
|
||||
if (pending) {
|
||||
exportState = completePendingTextForExport(canvasState, pending.draft);
|
||||
commitCanvas(exportState);
|
||||
const completed = exportState.elements.find((element) => element.element_id === pending.elementId);
|
||||
if (completed && completed.template_or_asset_id !== pending.originalTemplateId) {
|
||||
void recordRecentTextTemplate(completed.template_or_asset_id, completed.resource_version);
|
||||
}
|
||||
}
|
||||
|
||||
const queue = queueRef.current;
|
||||
const projectSaved = saveStatus !== "conflicted" && Boolean(queue && await queue.saveNow());
|
||||
const settings = resolveExportSettings({ format: options.format, ratio: exportState.ratio, ...(options.quality === undefined ? {} : { quality: options.quality }) });
|
||||
const result = await runExportFlow({
|
||||
compose: async () => reuseBlob ?? composeCanvasExport({
|
||||
canvasState: exportState,
|
||||
fontStatuses,
|
||||
format: options.format,
|
||||
projectId,
|
||||
...(options.quality === undefined ? {} : { quality: options.quality }),
|
||||
}),
|
||||
download: (blob) => downloadExportBlob(blob, exportFilename(project.name, options.format)),
|
||||
persist: async (blob) => {
|
||||
if (!projectSaved || !queue) throw new Error("project_state_not_saved");
|
||||
await saveLatestExport({
|
||||
blob,
|
||||
csrfToken: session.csrf_token,
|
||||
format: options.format,
|
||||
height: settings.height,
|
||||
projectId,
|
||||
stateVersion: queue.stateVersion,
|
||||
width: settings.width,
|
||||
});
|
||||
},
|
||||
});
|
||||
setExportResult({ blob: result.blob, format: options.format, status: result.status, ...(options.quality === undefined ? {} : { quality: options.quality }) });
|
||||
} catch {
|
||||
setExportResult({ blob: null, format: options.format, status: "composition_failed", ...(options.quality === undefined ? {} : { quality: options.quality }) });
|
||||
} finally {
|
||||
setExportBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startExport(options: { format: ExportFormat; quality?: number }) {
|
||||
if (saveStatus !== "conflicted") {
|
||||
await executeExport(options);
|
||||
return;
|
||||
}
|
||||
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
||||
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
|
||||
}
|
||||
|
||||
async function retryExportDownload() {
|
||||
if (!exportResult?.blob) return;
|
||||
await executeExport({ format: exportResult.format, ...(exportResult.quality === undefined ? {} : { quality: exportResult.quality }) }, exportResult.blob);
|
||||
}
|
||||
|
||||
function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || selectedIds.length === 0) return;
|
||||
try {
|
||||
action(controller);
|
||||
commitElementOperation(controller, message);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("对象操作未完成");
|
||||
}
|
||||
}
|
||||
|
||||
function beginOpacityGesture() {
|
||||
if (!canvasState || selectedIds.length === 0) return;
|
||||
opacityGestureRef.current = { base: canvasState, last: canvasState, selectedIds: [...selectedIds] };
|
||||
}
|
||||
|
||||
function changeOpacity(value: number) {
|
||||
const gesture = opacityGestureRef.current;
|
||||
if (!gesture) {
|
||||
transformSelection((controller) => { controller.setSelectedOpacity(value); }, "贴纸透明度已提交");
|
||||
return;
|
||||
}
|
||||
const previewController = new CanvasElementController(gesture.base);
|
||||
previewController.selectIds(gesture.selectedIds);
|
||||
previewController.setSelectedOpacity(value);
|
||||
gesture.last = previewController.value;
|
||||
setCanvasState(gesture.last);
|
||||
}
|
||||
|
||||
function commitOpacityGesture() {
|
||||
const gesture = opacityGestureRef.current;
|
||||
if (!gesture) return;
|
||||
opacityGestureRef.current = undefined;
|
||||
if (gesture.last === gesture.base) return;
|
||||
commitCanvas(gesture.last);
|
||||
setNotice("贴纸透明度已提交");
|
||||
}
|
||||
|
||||
function duplicateSelection() {
|
||||
transformSelection((controller) => controller.duplicateSelected(() => newElementIdentity()), "已复制选中对象");
|
||||
}
|
||||
|
||||
function changeSelectionLayer(command: CanvasLayerCommand) {
|
||||
const labels: Record<CanvasLayerCommand, string> = { back: "已置底", backward: "已下移一层", forward: "已上移一层", front: "已置顶" };
|
||||
transformSelection((controller) => { controller.changeLayer(command); }, labels[command]);
|
||||
}
|
||||
|
||||
function deleteSelection() {
|
||||
transformSelection((controller) => { controller.deleteSelected(); }, "已删除选中对象");
|
||||
}
|
||||
|
||||
function copySelection() {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || selectedIds.length === 0) return;
|
||||
clipboardRef.current = controller.copySelected();
|
||||
setNotice("已复制到画布剪贴板");
|
||||
}
|
||||
|
||||
function pasteSelection() {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || clipboardRef.current.length === 0) return;
|
||||
try {
|
||||
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||
commitElementOperation(controller, "已粘贴画布对象");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
}
|
||||
}
|
||||
|
||||
function selectAt(point: CanvasPoint, append: boolean) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || !canvasState) return false;
|
||||
const candidates = controller.candidatesAt(point);
|
||||
const selection = controller.selectAt(point, { append: append || multiMode });
|
||||
setSelectedIds(selection);
|
||||
setCandidateMenu(undefined);
|
||||
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
|
||||
return candidates.length > 0;
|
||||
}
|
||||
|
||||
function previewMove(delta: CanvasPoint) {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const previewController = new CanvasElementController(drag.base);
|
||||
previewController.selectIds(drag.selectedIds);
|
||||
const preview = previewController.moveSelected(delta);
|
||||
drag.last = preview.state;
|
||||
setCanvasState(preview.state);
|
||||
setGuides(preview.guides);
|
||||
}
|
||||
|
||||
function commitMove() {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
commitCanvas(drag.last);
|
||||
setNotice("对象位置已提交");
|
||||
setGuides([]);
|
||||
dragRef.current = undefined;
|
||||
}
|
||||
|
||||
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
||||
setCandidateMenu(undefined);
|
||||
}
|
||||
|
||||
function showCandidates(point: CanvasPoint) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
const elements = controller.candidatesAt(point);
|
||||
if (elements.length > 0) setCandidateMenu({ elements, point });
|
||||
}
|
||||
|
||||
function showAllCandidates() {
|
||||
if (!canvasState) return;
|
||||
const elements = [...canvasState.elements].sort((left, right) => right.z_index - left.z_index);
|
||||
if (elements.length > 0) setCandidateMenu({ elements, point: { x: 0.5, y: 0.08 } });
|
||||
}
|
||||
|
||||
function chooseCandidate(elementId: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
setSelectedIds(controller.selectById(elementId, multiMode));
|
||||
setCandidateMenu(undefined);
|
||||
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
|
||||
}
|
||||
|
||||
function closeCandidateMenu() {
|
||||
setCandidateMenu(undefined);
|
||||
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
|
||||
}
|
||||
|
||||
function handleCandidateMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeCandidateMenu();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||
const next = (current + delta + items.length) % items.length;
|
||||
event.preventDefault();
|
||||
items[next]?.focus();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
elementControllerRef.current?.clearSelection();
|
||||
setSelectedIds([]);
|
||||
setCandidateMenu(undefined);
|
||||
setGuides([]);
|
||||
}
|
||||
|
||||
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||||
const renderedCanvasState = textEdit ? {
|
||||
...canvasState,
|
||||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||||
} : canvasState;
|
||||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||
const canEdit = saveStatus !== "conflicted";
|
||||
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type !== "text_template")
|
||||
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
|
||||
: undefined;
|
||||
return (
|
||||
<div className="editor-page-shell">
|
||||
<a className="editor-skip-link" href="#editor-main">跳到主要内容</a>
|
||||
<header aria-label="编辑器顶部区域" className="editor-toolbar">
|
||||
<div aria-label="编辑器顶部工具栏" className="editor-toolbar-controls" role="toolbar">
|
||||
<a aria-label="返回项目" className="editor-back" href={`/app/projects/${projectId}`}>←</a>
|
||||
<div className="editor-title"><h1>{project.name}</h1><span>{project.ratio}</span></div>
|
||||
<div className="editor-history-actions">
|
||||
<button aria-label="撤销" disabled={!historyRef.current?.canUndo || !canEdit} onClick={undo} title="撤销" type="button">↶</button>
|
||||
<button aria-label="重做" disabled={!historyRef.current?.canRedo || !canEdit} onClick={redo} title="重做" type="button">↷</button>
|
||||
</div>
|
||||
<span aria-live={saveStatus === "failed" || saveStatus === "conflicted" ? "assertive" : "polite"} className={`editor-save-status ${saveStatus}`}>{saveStatusLabel(saveStatus)}</span>
|
||||
<button disabled type="button">预览</button>
|
||||
<button disabled={!canvasState.background.asset_id || (saveStatus === "conflicted" && conflictExportGuardRef.current.used)} onClick={openExportDialog} type="button">导出</button>
|
||||
</div>
|
||||
</header>
|
||||
{notice ? <p aria-live="polite" className="editor-notice" role="status">{notice}</p> : null}
|
||||
<main className="editor-layout" id="editor-main" tabIndex={-1}>
|
||||
<aside aria-label="素材与底图来源" className="editor-assets-panel">
|
||||
<nav aria-label="编辑器素材分类" className="editor-asset-tabs">
|
||||
{([
|
||||
{ label: "底图", panel: "background" as const },
|
||||
{ label: "历史", panel: "history" as const },
|
||||
{ label: "文字模板", panel: "text" as const },
|
||||
{ label: "普通贴纸", panel: "stickers" as const },
|
||||
{ label: "色卡", panel: "color" as const },
|
||||
{ label: "动态贴纸", panel: "dynamic" as const },
|
||||
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => { setActivePanel(item.panel); if (item.panel === "stickers") setStickerScrollTop(0); }} type="button">{item.label}</button>)}
|
||||
</nav>
|
||||
{activePanel === "background" ? <section><h2>底图</h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span>当前底图</span></button></section> : null}
|
||||
{activePanel === "history" ? <section><h2>历史</h2><div className="editor-history-list">
|
||||
{project.images.length === 0 ? <p className="editor-muted">暂无其他成功图</p> : project.images.map((image) => <button className="editor-source" key={image.image_id} onClick={() => setPendingBackground(image.image_id)} type="button"><span className="editor-thumb" style={{ backgroundImage: `url(/api/v1/private-assets/projects/${projectId}/images/${image.image_id})` }} /><span>生成图 · {formatDate(image.created_at)}</span></button>)}
|
||||
</div></section> : null}
|
||||
{activePanel === "text" ? <TextTemplatePanel
|
||||
canAdd={canEdit && canvasState.elements.length < 50}
|
||||
fontStatuses={fontStatuses}
|
||||
onAdd={(template) => { void addTextTemplate(template); }}
|
||||
onCategory={setTemplateCategory}
|
||||
onQuery={setTemplateQuery}
|
||||
onRetry={() => { void retryTextFonts(); }}
|
||||
query={templateQuery}
|
||||
recentIds={recentTextIds}
|
||||
templates={P0A_TEXT_TEMPLATES}
|
||||
{...(templateCategory ? { category: templateCategory } : {})}
|
||||
/> : null}
|
||||
{activePanel === "stickers" ? (() => {
|
||||
const visibleStickers = stickerWindow(P0A_STATIC_STICKER_CATALOG, stickerScrollTop, 280);
|
||||
return <section><h2>普通贴纸</h2><p aria-live="polite" className="editor-sticker-count">共 {P0A_STATIC_STICKER_COUNT.toLocaleString("zh-CN")} 张</p><div className="editor-sticker-virtual-list" data-testid="static-sticker-list" onScroll={(event) => setStickerScrollTop(event.currentTarget.scrollTop)} role="list">
|
||||
<div style={{ paddingTop: visibleStickers.top_spacer_px, paddingBottom: visibleStickers.bottom_spacer_px }}>
|
||||
<div className="editor-sticker-grid">
|
||||
{visibleStickers.items.map((sticker) => <button aria-label={`添加贴纸 ${sticker.stable_id}`} data-sticker-id={sticker.stable_id} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.stable_id} onClick={() => addSticker(sticker.stable_id)} type="button"><img alt="" className="editor-sticker-preview" decoding="async" loading="lazy" src={sticker.thumbnail_reference.url} /><strong>{sticker.stable_id}</strong><span>part{sticker.part} · {sticker.order}</span></button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status">画布最多 50 个元素,请先删除现有元素。</p> : null}</section>;
|
||||
})() : null}
|
||||
{activePanel === "color" ? <ColorCardPanel canAdd={canEdit && canvasState.elements.length < 50} hasBackground={Boolean(canvasState.background.asset_id)} onAdd={(definition) => { void addColorCard(definition); }} /> : null}
|
||||
{activePanel === "dynamic" ? <DynamicStickerPanel canAdd={canEdit && canvasState.elements.length < 50} fontStatuses={fontStatuses} onAdd={chooseDynamicSticker} /> : null}
|
||||
</aside>
|
||||
<section aria-label="画布工作区" className="editor-workspace">
|
||||
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
|
||||
<button aria-pressed={multiMode} className={multiMode ? "active" : ""} disabled={!canEdit} onClick={() => setMultiMode((current) => !current)} type="button">多选模式</button>
|
||||
<button disabled={canvasState.elements.length === 0} onClick={showAllCandidates} ref={candidateTriggerRef} type="button">循环选择</button>
|
||||
<button disabled={selectedElements.length === 0 || !canEdit} onClick={copySelection} title="复制" type="button">复制</button>
|
||||
<button disabled={clipboardRef.current.length === 0 || !canEdit} onClick={pasteSelection} title="粘贴" type="button">粘贴</button>
|
||||
</div>
|
||||
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
|
||||
<EditorStage
|
||||
assetId={canvasState.background.asset_id}
|
||||
canvasState={renderedCanvasState}
|
||||
fontStatuses={fontStatuses}
|
||||
guides={guides}
|
||||
onCandidates={showCandidates}
|
||||
onClearSelection={clearSelection}
|
||||
onCopy={copySelection}
|
||||
onDelete={deleteSelection}
|
||||
onMarquee={marqueeSelect}
|
||||
onMoveCommit={commitMove}
|
||||
onMovePreview={previewMove}
|
||||
onNudge={(delta) => transformSelection((controller) => { controller.moveSelected(delta, { snap: false }); }, "对象位置已提交")}
|
||||
onPaste={pasteSelection}
|
||||
onPointerMoved={() => elementControllerRef.current?.pointerMoved()}
|
||||
onSelect={selectAt}
|
||||
projectId={projectId}
|
||||
selectedIds={selectedIds}
|
||||
/>
|
||||
{candidateMenu ? <div aria-label="画布对象候选" className="editor-candidates" onKeyDown={handleCandidateMenuKeyDown} ref={candidateMenuRef} role="menu" style={{ left: `${candidateMenu.point.x * 100}%`, top: `${candidateMenu.point.y * 100}%` }}>
|
||||
{candidateMenu.elements.map((element) => <button key={element.element_id} onClick={() => chooseCandidate(element.element_id)} role="menuitem" type="button">{element.content || `${element.type} · ${element.template_or_asset_id}`}</button>)}
|
||||
</div> : null}
|
||||
</div>
|
||||
{guides.length > 0 ? <span aria-live="polite" className="editor-snap-status">已吸附</span> : null}
|
||||
</section>
|
||||
<aside aria-label={selectedElements.length > 0 ? "对象参数" : "底图参数"} className="editor-inspector">
|
||||
{selectedElements.length === 0 ? <>
|
||||
<header><p>BACKGROUND</p><h2>底图参数</h2></header>
|
||||
<label>适配方式<select disabled={!canEdit} value={draftAdjustments.fit} onChange={(event) => setDraftAdjustments((current) => ({ ...current, fit: event.target.value as typeof current.fit }))}><option value="fill">填充</option><option value="fit">适配</option><option value="crop">裁剪</option></select></label>
|
||||
<label>滤镜<select disabled={!canEdit} value={draftAdjustments.filter} onChange={(event) => setDraftAdjustments((current) => ({ ...current, filter: event.target.value }))}><option value="none">无滤镜</option><option value="grayscale">黑白</option><option value="sepia">复古</option></select></label>
|
||||
{(["brightness", "contrast", "saturation", "temperature", "sharpness"] as const).map((key) => <label className="editor-range" key={key}><span>{({ brightness: "亮度", contrast: "对比度", saturation: "饱和度", temperature: "色温", sharpness: "锐度" } as const)[key]}<output>{draftAdjustments[key]}</output></span><input disabled={!canEdit} max={100} min={key === "sharpness" ? 0 : -100} onChange={(event) => setDraftAdjustments((current) => ({ ...current, [key]: Number(event.target.value) }))} type="range" value={draftAdjustments[key]} /></label>)}
|
||||
<div className="editor-inspector-actions"><button disabled={!canEdit} onClick={() => { if (canvasState) setDraftAdjustments(resetBackgroundAdjustments(canvasState).background.adjustments); }} type="button">恢复原图</button><button className="editor-primary" disabled={!canEdit} onClick={applyPreview} type="button">应用调整</button></div>
|
||||
</> : <>
|
||||
<header><p>{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}</p><h2>{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}</h2></header>
|
||||
{selectedElements.length === 1 ? <p className="editor-object-id">{selectedElements[0]?.template_or_asset_id}</p> : null}
|
||||
{textEdit && selectedElements.length === 1 ? canEdit ? <TextInspector
|
||||
draft={textEdit.draft}
|
||||
onCancel={cancelTextEdit}
|
||||
onComplete={completeTextEdit}
|
||||
onContent={(content) => updateTextDraft((edit) => edit.setContent(content))}
|
||||
onFont={(fontId) => { void changeTextFont(fontId); }}
|
||||
onFontSize={(value) => updateTextDraft((edit) => edit.setEffectiveFontSize(value))}
|
||||
onStyle={(style: TextStylePatch) => updateTextDraft((edit) => edit.setStyle(style))}
|
||||
onTemplate={(templateId) => { void changeTextTemplate(templateId); }}
|
||||
templates={P0A_TEXT_TEMPLATES}
|
||||
/> : <p className="editor-muted">版本冲突,文字属性只读</p> : null}
|
||||
{selectedElements.length === 1 && selectedElements[0]?.type === "dynamic_sticker" && canEdit
|
||||
? <DynamicInspector element={selectedElements[0]} onCommit={(value) => commitDynamicOverride(selectedElements[0]!, value)} />
|
||||
: null}
|
||||
<div className="editor-object-moves" role="group" aria-label="移动对象">
|
||||
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button">←</button>
|
||||
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button">↑</button>
|
||||
<button aria-label="向下移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: 0.01 }, { snap: false }); }, "对象位置已提交")} title="向下移动" type="button">↓</button>
|
||||
<button aria-label="向右移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向右移动" type="button">→</button>
|
||||
</div>
|
||||
<div className="editor-object-tools">
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(0.9); }, "对象尺寸已提交")} type="button">缩小</button>
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(1.1); }, "对象尺寸已提交")} type="button">放大</button>
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(-15); }, "对象旋转已提交")} type="button">逆时针</button>
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(15); }, "对象旋转已提交")} type="button">顺时针</button>
|
||||
</div>
|
||||
{selectedStickerOpacity !== undefined ? <>
|
||||
<button className="editor-wide-command" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.flipSelectedHorizontal(); }, "贴纸翻转已提交")} type="button">水平翻转</button>
|
||||
<label className="editor-range"><span>透明度<output>{selectedStickerOpacity}%</output></span><input disabled={!canEdit} max={100} min={0} onBlur={commitOpacityGesture} onChange={(event) => changeOpacity(Number(event.target.value) / 100)} onPointerCancel={commitOpacityGesture} onPointerDown={beginOpacityGesture} onPointerUp={commitOpacityGesture} type="range" value={selectedStickerOpacity} /></label>
|
||||
</> : null}
|
||||
<div className="editor-layer-tools" role="group" aria-label="对象层级">
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("front")} type="button">置顶</button>
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("forward")} type="button">上移一层</button>
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("backward")} type="button">下移一层</button>
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("back")} type="button">置底</button>
|
||||
</div>
|
||||
<div className="editor-object-actions"><button disabled={!canEdit} onClick={duplicateSelection} type="button">复制</button><button className="editor-danger" disabled={!canEdit} onClick={deleteSelection} type="button">删除</button></div>
|
||||
</>}
|
||||
</aside>
|
||||
</main>
|
||||
<footer aria-label="画布状态" className="editor-statusbar"><span>画布 {canvasState.pixel_width} × {canvasState.pixel_height}</span><span>对象 {canvasState.elements.length} / 50</span><span>缩放 100%</span><span>本机保存 · state version {project.state_version}</span></footer>
|
||||
{pendingBackground ? <EditorDialog labelledBy="editor-background-confirm" onClose={() => setPendingBackground(undefined)}><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm">更换底图</h2><p>覆盖元素会保留,底图编辑参数将重置。</p><div><button className="editor-primary" data-dialog-initial-focus onClick={() => { void confirmBackground(); }} type="button">确认更换</button><button onClick={() => setPendingBackground(undefined)} type="button">取消</button></div></EditorDialog> : null}
|
||||
{locationDialog ? <EditorDialog className="editor-confirm editor-location-consent" labelledBy="editor-location-consent" onClose={() => { if (!locationDialog.pending) setLocationDialog(undefined); }}>
|
||||
<p>LOCATION PRIVACY</p><h2 id="editor-location-consent">使用自动定位</h2>
|
||||
<p>原始经纬度会保存到当前项目并显示在导出成品中。只有确认后才会请求浏览器定位,并由本地后端调用地点服务。</p>
|
||||
{locationDialog.error ? <p className="editor-limit" role="alert">{locationDialog.error}</p> : null}
|
||||
<label>手动地点文字<input aria-label="手动地点文字" disabled={locationDialog.pending} onChange={(event) => setLocationDialog((current) => current ? { ...current, manualValue: event.target.value } : current)} value={locationDialog.manualValue} /></label>
|
||||
<div className="editor-location-actions"><button className="editor-primary" data-dialog-initial-focus disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button">改用手动地点贴纸</button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button">暂不定位</button></div>
|
||||
</EditorDialog> : null}
|
||||
{exportOpen ? <ExportDialog
|
||||
busy={exportBusy}
|
||||
onClose={() => { if (!exportBusy) { setExportOpen(false); setExportResult(undefined); } }}
|
||||
onExport={(options) => { void startExport(options); }}
|
||||
onRetry={() => { void retryExportDownload(); }}
|
||||
pendingEdit={Boolean(pendingTextDraft())}
|
||||
ratio={canvasState.ratio}
|
||||
{...(exportResult?.blob ? { byteSize: exportResult.blob.size } : {})}
|
||||
{...(exportResult ? { result: exportResult.status } : {})}
|
||||
/> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { cssFilterForBackground } from "./editor-canvas.js";
|
||||
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
|
||||
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
||||
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { fontIdForTextElement } from "./text-assets.js";
|
||||
import { drawColorCard } from "./palette-provider.js";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
hit: boolean;
|
||||
longPressOpened: boolean;
|
||||
pointerId: number;
|
||||
start: CanvasPoint;
|
||||
}
|
||||
|
||||
interface EditorStageProps {
|
||||
assetId: string | null;
|
||||
canvasState: CanvasState;
|
||||
guides: readonly string[];
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
onCandidates: (point: CanvasPoint) => void;
|
||||
onClearSelection: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
||||
onMoveCommit: () => void;
|
||||
onMovePreview: (delta: CanvasPoint) => void;
|
||||
onNudge: (delta: CanvasPoint) => void;
|
||||
onPaste: () => void;
|
||||
onPointerMoved: () => void;
|
||||
onSelect: (point: CanvasPoint, append: boolean) => boolean;
|
||||
projectId: string;
|
||||
selectedIds: readonly string[];
|
||||
}
|
||||
|
||||
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
function styleValue<T extends string | number | boolean>(element: CanvasState["elements"][number], key: string, fallback: T): T {
|
||||
const value = element.style_parameters?.[key];
|
||||
return typeof value === typeof fallback ? value as T : fallback;
|
||||
}
|
||||
|
||||
interface TextPixelGeometry {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
|
||||
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
function measureTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string): TextPixelGeometry {
|
||||
prepareTextContext(context, element, fontId);
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const lineHeight = styleValue(element, "line_height", 1.2);
|
||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||
const strokeWidth = styleValue(element, "stroke_width", 0);
|
||||
const lines = (element.content ?? "").split("\n");
|
||||
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
|
||||
return {
|
||||
height: Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight) + 32 + strokeWidth * 2,
|
||||
width: Math.max(1, ...widths) + 32 + strokeWidth * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
|
||||
const fontId = fontIdForTextElement(element);
|
||||
if (!fontId || fontStatuses[fontId] !== "ready") {
|
||||
context.fillStyle = "#e5e7eb";
|
||||
context.fillRect(-110, -34, 220, 68);
|
||||
context.fillStyle = "#9f1d1d";
|
||||
context.font = "600 22px Microsoft YaHei UI, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.fillText("字体不可用", 0, 8);
|
||||
return;
|
||||
}
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const lineHeight = styleValue(element, "line_height", 1.2);
|
||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
|
||||
const lines = (element.content ?? "").split("\n");
|
||||
prepareTextContext(context, element, fontId);
|
||||
context.textAlign = align;
|
||||
context.textBaseline = "middle";
|
||||
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
|
||||
const textWidth = Math.max(1, ...widths);
|
||||
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
|
||||
const padding = 16;
|
||||
const backgroundEnabled = styleValue(element, "background_enabled", false);
|
||||
if (backgroundEnabled) {
|
||||
const elementOpacity = context.globalAlpha;
|
||||
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
|
||||
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
|
||||
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
|
||||
context.globalAlpha = elementOpacity;
|
||||
}
|
||||
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
|
||||
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
|
||||
context.fillStyle = styleValue(element, "fill_color", "#111111");
|
||||
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
|
||||
context.lineWidth = styleValue(element, "stroke_width", 0);
|
||||
lines.forEach((line, index) => {
|
||||
const y = firstY + index * fontSize * lineHeight;
|
||||
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
|
||||
context.fillText(line, anchorX, y);
|
||||
});
|
||||
}
|
||||
|
||||
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
|
||||
context.fillStyle = "#e5e7eb";
|
||||
context.fillRect(-140, -42, 280, 84);
|
||||
context.fillStyle = "#9f1d1d";
|
||||
context.font = "600 18px Microsoft YaHei UI, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(message, 0, 0);
|
||||
}
|
||||
|
||||
export function drawDynamicSticker(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
const id = element.template_or_asset_id as DynamicTemplateId;
|
||||
const model = DYNAMIC_RENDER_MODELS[id];
|
||||
if (!model) {
|
||||
drawDynamicUnavailable(context, "动态贴纸模型不可用");
|
||||
return;
|
||||
}
|
||||
if (dynamicFontOptionsFor(id).some((font) => fontStatuses[font.fontId] !== "ready")) {
|
||||
drawDynamicUnavailable(context, id === "DYN012" ? "FONT081 替代字体不可用" : "原版字体不可用");
|
||||
return;
|
||||
}
|
||||
if (model.imageLayers.some((layer) => !resourceImages[layer.assetId])) {
|
||||
drawDynamicUnavailable(context, "原版图片资源不可用");
|
||||
return;
|
||||
}
|
||||
context.textBaseline = "middle";
|
||||
for (const layer of model.imageLayers) {
|
||||
const image = resourceImages[layer.assetId]!;
|
||||
context.drawImage(image, layer.x - layer.width / 2, layer.y - layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
if (id === "DYN012") {
|
||||
const parts = dyn012DisplayParts(element);
|
||||
const layout = DYN012_RENDER_LAYOUT;
|
||||
context.fillStyle = layout.divider.color;
|
||||
context.fillRect(layout.divider.x - layout.divider.width / 2, layout.divider.y - layout.divider.height / 2, layout.divider.width, layout.divider.height);
|
||||
for (const [part, layer] of [[parts.hour, layout.hour], [parts.minute, layout.minute], [parts.meridiem, layout.meridiem]] as const) {
|
||||
context.fillStyle = layer.color;
|
||||
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||
context.textAlign = "center";
|
||||
context.fillText(part, layer.x, layer.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const layer of model.textLayers) {
|
||||
context.fillStyle = layer.color;
|
||||
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||
context.textAlign = layer.align;
|
||||
context.fillText(dynamicTextValue(element, layer.value), layer.x, layer.y);
|
||||
}
|
||||
}
|
||||
|
||||
function elementSelectionHalfSize(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
) {
|
||||
if (element.type === "color_card") {
|
||||
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
|
||||
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
|
||||
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
|
||||
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
|
||||
}
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
|
||||
}
|
||||
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
|
||||
const fontId = fontIdForTextElement(element);
|
||||
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
|
||||
context.save();
|
||||
const geometry = measureTextElement(context, element, fontId);
|
||||
context.restore();
|
||||
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
|
||||
}
|
||||
|
||||
function drawElement(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
width: number,
|
||||
height: number,
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
const x = element.position.x * width;
|
||||
const y = element.position.y * height;
|
||||
context.save();
|
||||
context.globalAlpha = element.opacity;
|
||||
context.translate(x, y);
|
||||
context.rotate((element.rotation * Math.PI) / 180);
|
||||
const flip = element.style_parameters?.flip_horizontal === true ? -1 : 1;
|
||||
context.scale(element.scale.x * flip, element.scale.y);
|
||||
if (element.type === "color_card") drawColorCard(context, element);
|
||||
else if (element.type === "static_sticker") {
|
||||
const image = resourceImages[element.template_or_asset_id];
|
||||
if (!image) drawDynamicUnavailable(context, "原版贴纸资源不可用");
|
||||
else {
|
||||
const scale = 156 / Math.max(image.naturalWidth, image.naturalHeight);
|
||||
const width = image.naturalWidth * scale;
|
||||
const height = image.naturalHeight * scale;
|
||||
context.drawImage(image, -width / 2, -height / 2, width, height);
|
||||
}
|
||||
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
|
||||
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function loadCanvasImage(url: string) {
|
||||
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(undefined);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
||||
const imageReferences = new Map<string, string>();
|
||||
for (const element of canvasState.elements) {
|
||||
if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id));
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
|
||||
}
|
||||
}
|
||||
return imageReferences;
|
||||
}
|
||||
|
||||
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
|
||||
const background = canvasState.background.asset_id
|
||||
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
||||
: Promise.resolve(undefined);
|
||||
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||
const [image, loaded] = await Promise.all([background, resources]);
|
||||
return {
|
||||
background: image,
|
||||
resourceImages: Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditorScene(
|
||||
context: CanvasRenderingContext2D,
|
||||
canvasState: CanvasState,
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
background: HTMLImageElement | undefined,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.filter = cssFilterForBackground(canvasState.background.adjustments);
|
||||
if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.filter = "none";
|
||||
for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) {
|
||||
drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredFontIds(canvasState: CanvasState) {
|
||||
return canvasState.elements.flatMap((element) => {
|
||||
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
|
||||
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderCanvasStateForExport(input: {
|
||||
canvasState: CanvasState;
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
projectId: string;
|
||||
}) {
|
||||
const missingFont = requiredFontIds(input.canvasState).find((fontId) => input.fontStatuses[fontId] !== "ready");
|
||||
if (missingFont) throw new Error("export_font_unavailable");
|
||||
await document.fonts.ready;
|
||||
const resources = await loadSceneResources(input.canvasState, input.projectId);
|
||||
if (input.canvasState.background.asset_id && !resources.background) throw new Error("export_background_unavailable");
|
||||
if (Object.keys(resources.resourceImages).length !== resourceUrlsForCanvas(input.canvasState).size) throw new Error("export_asset_unavailable");
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = input.canvasState.pixel_width;
|
||||
canvas.height = input.canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d", { colorSpace: "srgb" });
|
||||
if (!context) throw new Error("export_canvas_unavailable");
|
||||
renderEditorScene(context, input.canvasState, input.fontStatuses, resources.background, resources.resourceImages);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function EditorStage(props: EditorStageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
canvas.width = props.canvasState.pixel_width;
|
||||
canvas.height = props.canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "#005fcc";
|
||||
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||
const { width: halfWidth, height: halfHeight } = elementSelectionHalfSize(context, element, props.fontStatuses);
|
||||
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
|
||||
}
|
||||
context.save();
|
||||
context.strokeStyle = "#005fcc";
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([12, 8]);
|
||||
if (props.guides.some((guide) => guide.includes("-x"))) {
|
||||
context.beginPath(); context.moveTo(canvas.width / 2, 0); context.lineTo(canvas.width / 2, canvas.height); context.stroke();
|
||||
}
|
||||
if (props.guides.some((guide) => guide.includes("-y"))) {
|
||||
context.beginPath(); context.moveTo(0, canvas.height / 2); context.lineTo(canvas.width, canvas.height / 2); context.stroke();
|
||||
}
|
||||
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||
context.restore();
|
||||
};
|
||||
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
|
||||
if (!active) return;
|
||||
render(background, resourceImages);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
||||
|
||||
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||
if (event.button !== 0) return;
|
||||
const start = pointFromEvent(event);
|
||||
const append = event.shiftKey;
|
||||
const hit = props.onSelect(start, append);
|
||||
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
longPressRef.current = setTimeout(() => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || !gesture.hit) return;
|
||||
gesture.longPressOpened = true;
|
||||
props.onCandidates(gesture.start);
|
||||
}, 550);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLCanvasElement>) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) {
|
||||
props.onPointerMoved();
|
||||
return;
|
||||
}
|
||||
const point = pointFromEvent(event);
|
||||
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);
|
||||
else if (!gesture.hit) setMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y });
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLCanvasElement>) {
|
||||
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;
|
||||
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);
|
||||
setMarquee(undefined);
|
||||
gestureRef.current = undefined;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLCanvasElement>) {
|
||||
const step = event.shiftKey ? 0.01 : 0.002;
|
||||
if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); props.onDelete(); }
|
||||
else if (event.key === "Escape") { event.preventDefault(); props.onClearSelection(); }
|
||||
else if (event.ctrlKey && event.key.toLowerCase() === "c") { event.preventDefault(); props.onCopy(); }
|
||||
else if (event.ctrlKey && event.key.toLowerCase() === "v") { event.preventDefault(); props.onPaste(); }
|
||||
else if (event.key.startsWith("Arrow")) {
|
||||
event.preventDefault();
|
||||
props.onNudge({ x: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0, y: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return <canvas aria-label="编辑画布" className="editor-canvas" onKeyDown={handleKeyDown} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} ref={canvasRef} tabIndex={0} />;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { renderCanvasStateForExport } from "./editor-stage.js";
|
||||
import { P0A_TEXT_TEMPLATES, TextEditSession } from "./text-assets.js";
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
|
||||
export type ExportFormat = "jpg" | "png";
|
||||
export type ExportRatio = CanvasState["ratio"];
|
||||
|
||||
export const EXPORT_DIMENSIONS: Readonly<Record<ExportRatio, { height: number; width: number }>> = {
|
||||
"1:1": { height: 1080, width: 1080 },
|
||||
"3:4": { height: 1440, width: 1080 },
|
||||
"4:3": { height: 1080, width: 1440 },
|
||||
"9:16": { height: 1920, width: 1080 },
|
||||
};
|
||||
|
||||
export interface ExportSettings {
|
||||
format: ExportFormat;
|
||||
height: number;
|
||||
mimeType: "image/jpeg" | "image/png";
|
||||
quality?: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function resolveExportSettings(input: { format: ExportFormat; quality?: number; ratio: ExportRatio }): ExportSettings {
|
||||
const dimensions = EXPORT_DIMENSIONS[input.ratio];
|
||||
if (input.format === "png") return { format: "png", height: dimensions.height, mimeType: "image/png", width: dimensions.width };
|
||||
const quality = input.quality ?? 92;
|
||||
if (!Number.isInteger(quality) || quality < 80 || quality > 100) throw new Error("export_quality_invalid");
|
||||
return { format: "jpg", height: dimensions.height, mimeType: "image/jpeg", quality, width: dimensions.width };
|
||||
}
|
||||
|
||||
export function completePendingTextForExport(canvasState: CanvasState, draft: CanvasState["elements"][number]) {
|
||||
if (draft.type !== "text_template") throw new Error("export_pending_text_invalid");
|
||||
const index = canvasState.elements.findIndex((element) => element.element_id === draft.element_id && element.type === "text_template");
|
||||
if (index < 0) throw new Error("export_pending_text_missing");
|
||||
const complete = new TextEditSession(draft, P0A_TEXT_TEMPLATES).complete();
|
||||
const next = structuredClone(canvasState);
|
||||
next.elements[index] = complete;
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function sha256Blob(blob: Blob) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function encodeCanvasExport(canvas: HTMLCanvasElement, settings: ExportSettings) {
|
||||
if (canvas.width !== settings.width || canvas.height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const quality = settings.format === "jpg" ? settings.quality! / 100 : undefined;
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob || blob.type !== settings.mimeType) reject(new Error("export_encode_failed"));
|
||||
else resolve(blob);
|
||||
}, settings.mimeType, quality);
|
||||
});
|
||||
}
|
||||
|
||||
export async function composeCanvasExport(input: {
|
||||
canvasState: CanvasState;
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
format: ExportFormat;
|
||||
projectId: string;
|
||||
quality?: number;
|
||||
}) {
|
||||
const settings = resolveExportSettings({ format: input.format, ratio: input.canvasState.ratio, ...(input.quality === undefined ? {} : { quality: input.quality }) });
|
||||
if (input.canvasState.pixel_width !== settings.width || input.canvasState.pixel_height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
|
||||
const canvas = await renderCanvasStateForExport({ canvasState: input.canvasState, fontStatuses: input.fontStatuses, projectId: input.projectId });
|
||||
return encodeCanvasExport(canvas, settings);
|
||||
}
|
||||
|
||||
export async function downloadExportBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.download = filename;
|
||||
anchor.href = url;
|
||||
anchor.style.display = "none";
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function exportFilename(projectName: string, format: ExportFormat) {
|
||||
const safeName = projectName.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_").slice(0, 80) || "Dada成品";
|
||||
return `${safeName}.${format === "jpg" ? "jpg" : "png"}`;
|
||||
}
|
||||
|
||||
export async function saveLatestExport(input: {
|
||||
blob: Blob;
|
||||
csrfToken: string;
|
||||
format: ExportFormat;
|
||||
height: number;
|
||||
projectId: string;
|
||||
stateVersion: number;
|
||||
width: number;
|
||||
}) {
|
||||
const sha256 = await sha256Blob(input.blob);
|
||||
const form = new FormData();
|
||||
form.append("format", input.format);
|
||||
form.append("export_id", crypto.randomUUID());
|
||||
form.append("sha256", sha256);
|
||||
form.append("byte_size", String(input.blob.size));
|
||||
form.append("pixel_width", String(input.width));
|
||||
form.append("pixel_height", String(input.height));
|
||||
form.append("state_version", String(input.stateVersion));
|
||||
form.append("export_file", input.blob, `latest.${input.format}`);
|
||||
const response = await fetch(`/api/v1/projects/${encodeURIComponent(input.projectId)}/latest-exports/${input.format}`, {
|
||||
body: form,
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRF-Token": input.csrfToken },
|
||||
method: "PUT",
|
||||
});
|
||||
if (!response.ok) throw new Error(response.status === 409 ? "latest_export_conflict" : "latest_export_failed");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useDialogFocus } from "./dialog-focus.js";
|
||||
import { exportResultCopy, type ExportFlowStatus } from "./export-flow.js";
|
||||
import { EXPORT_DIMENSIONS, type ExportFormat, type ExportRatio } from "./export-compositor.js";
|
||||
|
||||
export function ExportDialog(props: {
|
||||
busy: boolean;
|
||||
byteSize?: number;
|
||||
onClose: () => void;
|
||||
onExport: (input: { format: ExportFormat; quality?: number }) => void;
|
||||
onRetry: () => void;
|
||||
pendingEdit: boolean;
|
||||
ratio: ExportRatio;
|
||||
result?: ExportFlowStatus;
|
||||
}) {
|
||||
const [format, setFormat] = useState<ExportFormat>("jpg");
|
||||
const [quality, setQuality] = useState(92);
|
||||
const [pendingConfirmed, setPendingConfirmed] = useState(false);
|
||||
const dimensions = EXPORT_DIMENSIONS[props.ratio];
|
||||
const failure = props.result && props.result !== "downloaded_and_saved";
|
||||
const dialogRef = useDialogFocus(props.onClose);
|
||||
return <div className="editor-dialog-backdrop"><section aria-labelledby="editor-export-title" aria-modal="true" className="editor-export-dialog" ref={dialogRef} role="dialog" tabIndex={-1}>
|
||||
<header><div><p>EXPORT</p><h2 id="editor-export-title">导出成品</h2></div></header>
|
||||
{props.result ? <div className={`editor-export-result ${failure ? "failed" : "succeeded"}`}>
|
||||
<p aria-live={failure ? "assertive" : "polite"} role={failure ? "alert" : "status"}>{exportResultCopy[props.result]}</p>
|
||||
{props.byteSize ? <small>{(props.byteSize / 1024 / 1024).toFixed(2)} MB</small> : null}
|
||||
<div>{props.result === "download_failed" ? <button disabled={props.busy} onClick={props.onRetry} type="button">重新下载同一次合成</button> : null}<button disabled={props.busy} onClick={props.onClose} type="button">关闭</button></div>
|
||||
</div> : <>
|
||||
<section className="editor-export-section"><h3>文件格式</h3><div aria-label="导出格式" className="editor-export-segments" role="group">
|
||||
<button aria-pressed={format === "jpg"} data-dialog-initial-focus onClick={() => setFormat("jpg")} type="button">JPG</button>
|
||||
<button aria-pressed={format === "png"} onClick={() => setFormat("png")} type="button">PNG</button>
|
||||
</div></section>
|
||||
{format === "jpg" ? <section className="editor-export-section"><div className="editor-export-label"><h3>JPG 质量</h3><output>{quality}</output></div><div className="editor-export-quality"><input aria-label="JPG 质量" max="100" min="80" onChange={(event) => setQuality(Number(event.target.value))} step="1" type="range" value={quality} /><input aria-label="JPG 质量数值" max="100" min="80" onChange={(event) => setQuality(Math.max(80, Math.min(100, Number(event.target.value))))} step="1" type="number" value={quality} /></div></section> : null}
|
||||
<section className="editor-export-summary"><span>输出尺寸</span><strong>{dimensions.width} × {dimensions.height} px</strong><span>色彩空间</span><strong>sRGB</strong><span>预计文件大小</span><strong>合成后显示</strong></section>
|
||||
{props.pendingEdit ? <label className="editor-export-pending"><strong>导出前需要提交当前修改</strong><span><input checked={pendingConfirmed} onChange={(event) => setPendingConfirmed(event.target.checked)} type="checkbox" />将应用当前修改并导出</span></label> : null}
|
||||
<footer><button className="editor-primary" disabled={props.busy || (props.pendingEdit && !pendingConfirmed)} onClick={() => props.onExport({ format, ...(format === "jpg" ? { quality } : {}) })} type="button">{props.busy ? "正在导出" : "导出并下载"}</button><button disabled={props.busy} onClick={props.onClose} type="button">取消</button></footer>
|
||||
</>}
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type ExportFlowStatus = "composition_failed" | "download_failed" | "downloaded_not_saved" | "downloaded_and_saved";
|
||||
|
||||
export const exportResultCopy: Record<ExportFlowStatus, string> = {
|
||||
composition_failed: "合成失败,请返回编辑器检查",
|
||||
download_failed: "浏览器下载失败,可重新下载同一次合成结果",
|
||||
downloaded_not_saved: "文件已下载,但未保存为最新成品",
|
||||
downloaded_and_saved: "已下载并保存为最新成品",
|
||||
};
|
||||
|
||||
export async function runExportFlow(input: {
|
||||
compose: () => Promise<Blob>;
|
||||
download: (blob: Blob) => Promise<void>;
|
||||
persist: (blob: Blob) => Promise<void>;
|
||||
}): Promise<{ blob: Blob | null; status: ExportFlowStatus }> {
|
||||
let blob: Blob;
|
||||
try {
|
||||
blob = await input.compose();
|
||||
} catch {
|
||||
return { blob: null, status: "composition_failed" };
|
||||
}
|
||||
try {
|
||||
await input.download(blob);
|
||||
} catch {
|
||||
return { blob, status: "download_failed" };
|
||||
}
|
||||
try {
|
||||
await input.persist(blob);
|
||||
return { blob, status: "downloaded_and_saved" };
|
||||
} catch {
|
||||
return { blob, status: "downloaded_not_saved" };
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,316 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
export async function checkBrowserSupport(body: {
|
||||
"brands": Array<{
|
||||
"brand": string;
|
||||
"version": string;
|
||||
}>;
|
||||
"full_version_list": Array<{
|
||||
"brand": string;
|
||||
"version": string;
|
||||
}>;
|
||||
"platform": string;
|
||||
}, options: ClientOptions = {}): Promise<{
|
||||
"app_version": string;
|
||||
"browser": {
|
||||
"brand": "Google Chrome" | "Microsoft Edge";
|
||||
"major": number;
|
||||
};
|
||||
"status": "supported";
|
||||
"supported_browsers": Array<{
|
||||
"brand": "Google Chrome" | "Microsoft Edge";
|
||||
"major": number;
|
||||
}>;
|
||||
}> {
|
||||
export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credit-adjustments`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditAdjustmentResponse>;
|
||||
}
|
||||
|
||||
export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise<BrowserSupportSuccess> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/support/check`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<{
|
||||
"app_version": string;
|
||||
"browser": {
|
||||
"brand": "Google Chrome" | "Microsoft Edge";
|
||||
"major": number;
|
||||
};
|
||||
"status": "supported";
|
||||
"supported_browsers": Array<{
|
||||
"brand": "Google Chrome" | "Microsoft Edge";
|
||||
"major": number;
|
||||
}>;
|
||||
}>;
|
||||
return response.json() as Promise<BrowserSupportSuccess>;
|
||||
}
|
||||
|
||||
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||
"app_version": string;
|
||||
"dependencies": Array<{
|
||||
"service_id": string;
|
||||
"status": "available" | "paused" | "degraded" | "unavailable";
|
||||
}>;
|
||||
"model_summary": {
|
||||
"config_set_version": number | null;
|
||||
"configured_default_model_id": string | null;
|
||||
"recommended_model_id": string | null;
|
||||
"runtime_availability_version": number | null;
|
||||
};
|
||||
"public_features": Array<{
|
||||
"feature_id": string;
|
||||
"status": "enabled" | "disabled" | "paused";
|
||||
}>;
|
||||
}> {
|
||||
export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise<AccountDeletionResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountDeletionResponse>;
|
||||
}
|
||||
|
||||
export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AdminLoginCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<LoginCompleteResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<LoginCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise<RegistrationCompleteResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/register/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function createGeneration(body: FormData, options: ClientOptions = {}): Promise<GenerationCreateResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { body: body, method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<GenerationCreateResponse>;
|
||||
}
|
||||
|
||||
export async function downloadLatestExport(options: ClientOptions = {}): Promise<Blob> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.blob() as Promise<Blob>;
|
||||
}
|
||||
|
||||
export async function downloadOriginalGeneration(options: ClientOptions = {}): Promise<Blob> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/private-assets/projects/{projectId}/images/{imageId}`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.blob() as Promise<Blob>;
|
||||
}
|
||||
|
||||
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountSettingsResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AdminSessionResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminUserCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credits`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditBalanceResponse>;
|
||||
}
|
||||
|
||||
export async function getBootstrap(options: ClientOptions = {}): Promise<BootstrapResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<{
|
||||
"app_version": string;
|
||||
"dependencies": Array<{
|
||||
"service_id": string;
|
||||
"status": "available" | "paused" | "degraded" | "unavailable";
|
||||
}>;
|
||||
"model_summary": {
|
||||
"config_set_version": number | null;
|
||||
"configured_default_model_id": string | null;
|
||||
"recommended_model_id": string | null;
|
||||
"runtime_availability_version": number | null;
|
||||
};
|
||||
"public_features": Array<{
|
||||
"feature_id": string;
|
||||
"status": "enabled" | "disabled" | "paused";
|
||||
}>;
|
||||
}>;
|
||||
return response.json() as Promise<BootstrapResponse>;
|
||||
}
|
||||
|
||||
export async function getCurrentGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/current`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<GenerationTaskResponse>;
|
||||
}
|
||||
|
||||
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
|
||||
return `${options.baseUrl ?? ""}/api/v1/events`;
|
||||
}
|
||||
|
||||
export async function getGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/{generationId}`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<GenerationTaskResponse>;
|
||||
}
|
||||
|
||||
export async function getModel(options: ClientOptions = {}): Promise<ModelConfig> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/models/{model_id}`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ModelConfig>;
|
||||
}
|
||||
|
||||
export async function getModels(options: ClientOptions = {}): Promise<ModelConfigurationResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/models`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ModelConfigurationResponse>;
|
||||
}
|
||||
|
||||
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditLedgerResponse>;
|
||||
}
|
||||
|
||||
export async function getMyCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credits`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditBalanceResponse>;
|
||||
}
|
||||
|
||||
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectDetailResponse>;
|
||||
}
|
||||
|
||||
export async function getUserSession(options: ClientOptions = {}): Promise<UserSessionResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<UserSessionResponse>;
|
||||
}
|
||||
|
||||
export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectListResponse>;
|
||||
}
|
||||
|
||||
export async function listRecentAssets(options: ClientOptions = {}): Promise<RecentAssetListResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/assets/recent`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RecentAssetListResponse>;
|
||||
}
|
||||
|
||||
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<LogoutResponse>;
|
||||
}
|
||||
|
||||
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectPurgeResponse>;
|
||||
}
|
||||
|
||||
export async function recordRecentAsset(body: RecentAssetRecordRequest, options: ClientOptions = {}): Promise<RecentAssetRecordResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/assets/recent`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RecentAssetRecordResponse>;
|
||||
}
|
||||
|
||||
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { body: JSON.stringify(body), method: "PATCH", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectRenameResponse>;
|
||||
}
|
||||
|
||||
export async function replaceModelConfiguration(body: ModelConfigUpdateRequest, options: ClientOptions = {}): Promise<ModelConfigurationResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/models/configuration`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ModelConfigurationResponse>;
|
||||
}
|
||||
|
||||
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectRestoreResponse>;
|
||||
}
|
||||
|
||||
export async function reverseGeocodeLocation(body: ReverseGeocodeRequest, options: ClientOptions = {}): Promise<ReverseGeocodeResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/location/reverse-geocode`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ReverseGeocodeResponse>;
|
||||
}
|
||||
|
||||
export async function saveLatestExport(body: FormData, options: ClientOptions = {}): Promise<LatestExportSaveResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { body: body, method: "PUT", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<LatestExportSaveResponse>;
|
||||
}
|
||||
|
||||
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/state`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectStateSaveResponse>;
|
||||
}
|
||||
|
||||
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountDeletionSendResponse>;
|
||||
}
|
||||
|
||||
export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationSendResponse>;
|
||||
}
|
||||
|
||||
export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationSendResponse>;
|
||||
}
|
||||
|
||||
export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/register/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationSendResponse>;
|
||||
}
|
||||
|
||||
export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, options: ClientOptions = {}): Promise<FailedEmptyTrashResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/failed-empty/trash`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<FailedEmptyTrashResponse>;
|
||||
}
|
||||
|
||||
export async function trashProject(options: ClientOptions = {}): Promise<ProjectTrashResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/trash`, { method: "POST", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectTrashResponse>;
|
||||
}
|
||||
|
||||
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings/profile`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountProfileUpdateResponse>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,100 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
export type AccountDeletionCompleteRequest = {
|
||||
"confirmation": "注销账号";
|
||||
"deletion_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type AccountDeletionResponse = {
|
||||
"status": "deleted";
|
||||
};
|
||||
|
||||
export type AccountDeletionSendResponse = {
|
||||
"challenge_expires_at": string;
|
||||
"deletion_id": string;
|
||||
"resend_available_at": string;
|
||||
"status": "verification_sent";
|
||||
};
|
||||
|
||||
export type AccountProfileUpdateRequest = {
|
||||
"creator_name": string;
|
||||
"social_id": string;
|
||||
};
|
||||
|
||||
export type AccountProfileUpdateResponse = {
|
||||
"profile": {
|
||||
"creator_name": string;
|
||||
"social_id": string;
|
||||
};
|
||||
"status": "saved";
|
||||
};
|
||||
|
||||
export type AccountSettingsResponse = {
|
||||
"account": {
|
||||
"email": string;
|
||||
"status": "active";
|
||||
};
|
||||
"csrf_token": string;
|
||||
"local_data": {
|
||||
"backup_enabled": false;
|
||||
"capacity_status": "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
"hard_limit_bytes": number;
|
||||
"location": "configured_local_data_root";
|
||||
"managed_content_bytes": number;
|
||||
"migration_supported": false;
|
||||
};
|
||||
"profile": {
|
||||
"creator_name": string;
|
||||
"social_id": string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminAuthenticatedUser = {
|
||||
"role": "super_admin";
|
||||
"status": "active";
|
||||
"user_id": string;
|
||||
};
|
||||
|
||||
export type AdminCreditParams = {
|
||||
"userId": string;
|
||||
};
|
||||
|
||||
export type AdminLoginCompleteRequest = {
|
||||
"registration_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type AdminLoginCompleteResponse = {
|
||||
"admin": AdminAuthenticatedUser;
|
||||
"audience": "admin";
|
||||
"session_expires_at": string;
|
||||
"status": "authenticated";
|
||||
};
|
||||
|
||||
export type AdminLoginSendRequest = {
|
||||
"email": string;
|
||||
};
|
||||
|
||||
export type AdminSessionResponse = {
|
||||
"acknowledged_private_content_notice_version": string | null;
|
||||
"admin": AdminAuthenticatedUser;
|
||||
"audience": "admin";
|
||||
"authenticated": true;
|
||||
"csrf_token": string;
|
||||
"current_private_content_notice_version": string | null;
|
||||
"expires_at": string;
|
||||
"notice_acknowledged": boolean;
|
||||
};
|
||||
|
||||
export type AuthenticatedUser = {
|
||||
"creator_name": string;
|
||||
"role": "user";
|
||||
"social_id": string;
|
||||
"status": "active";
|
||||
"user_id": string;
|
||||
};
|
||||
|
||||
export type BootstrapResponse = {
|
||||
"app_version": string;
|
||||
"dependencies": Array<{
|
||||
@@ -45,10 +140,140 @@ export type BrowserSupportSuccess = {
|
||||
|
||||
export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
|
||||
|
||||
export type CanvasBackgroundAdjustments = {
|
||||
"brightness": number;
|
||||
"contrast": number;
|
||||
"crop": {
|
||||
"height": number;
|
||||
"width": number;
|
||||
"x": number;
|
||||
"y": number;
|
||||
} | null;
|
||||
"filter": string;
|
||||
"fit": "fill" | "fit" | "crop";
|
||||
"saturation": number;
|
||||
"sharpness": number;
|
||||
"temperature": number;
|
||||
};
|
||||
|
||||
export type CanvasElement = {
|
||||
"colors"?: Array<string>;
|
||||
"content"?: string;
|
||||
"coordinates"?: {
|
||||
"latitude": number;
|
||||
"longitude": number;
|
||||
};
|
||||
"created_at": string;
|
||||
"dynamic_fields"?: Record<string, string | number | boolean | null>;
|
||||
"element_id": string;
|
||||
"font_override"?: string;
|
||||
"font_size"?: number;
|
||||
"formatted_value"?: string;
|
||||
"opacity": number;
|
||||
"position": {
|
||||
"x": number;
|
||||
"y": number;
|
||||
};
|
||||
"resource_version": string;
|
||||
"rotation": number;
|
||||
"scale": {
|
||||
"x": number;
|
||||
"y": number;
|
||||
};
|
||||
"style_id"?: string;
|
||||
"style_parameters"?: Record<string, string | number | boolean | null>;
|
||||
"template_or_asset_id": string;
|
||||
"type": "text_template" | "static_sticker" | "color_card" | "dynamic_sticker";
|
||||
"z_index": number;
|
||||
};
|
||||
|
||||
export type CanvasState = {
|
||||
"background": {
|
||||
"adjustments": CanvasBackgroundAdjustments;
|
||||
"asset_id": string | null;
|
||||
};
|
||||
"elements": Array<CanvasElement>;
|
||||
"pixel_height": number;
|
||||
"pixel_width": number;
|
||||
"ratio": "3:4" | "1:1" | "4:3" | "9:16";
|
||||
"schema_version": 1;
|
||||
};
|
||||
|
||||
export type CorrelationId = string;
|
||||
|
||||
export type CreditAdjustmentHeaders = {
|
||||
"idempotency-key": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type CreditAdjustmentRequest = {
|
||||
"adjustment_id": string;
|
||||
"amount": number | number;
|
||||
"reason": string;
|
||||
};
|
||||
|
||||
export type CreditAdjustmentResponse = {
|
||||
"adjustment_id": string;
|
||||
"available_balance": number;
|
||||
"reserved_balance": number;
|
||||
"status": "adjusted";
|
||||
};
|
||||
|
||||
export type CreditBalanceResponse = {
|
||||
"available_balance": number;
|
||||
"reserved_balance": number;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type CreditEntryStatus = "succeeded" | "frozen" | "committed" | "released";
|
||||
|
||||
export type CreditEntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||
|
||||
export type CreditLedgerEntry = {
|
||||
"amount": number;
|
||||
"available_after": number;
|
||||
"available_before": number;
|
||||
"created_at": string;
|
||||
"entry_id": string;
|
||||
"entry_type": CreditEntryType;
|
||||
"model_id": string | null;
|
||||
"reason": string | null;
|
||||
"reference_id": string | null;
|
||||
"reference_type": CreditReferenceType | null;
|
||||
"reserved_after": number;
|
||||
"reserved_before": number;
|
||||
"status": CreditEntryStatus;
|
||||
};
|
||||
|
||||
export type CreditLedgerQuery = {
|
||||
"cursor"?: string;
|
||||
"event_type"?: CreditEntryType;
|
||||
"from"?: string;
|
||||
"limit"?: number;
|
||||
"to"?: string;
|
||||
};
|
||||
|
||||
export type CreditLedgerResponse = {
|
||||
"credits": CreditSummary;
|
||||
"entries": Array<CreditLedgerEntry>;
|
||||
"next_cursor": string | null;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type CreditReferenceType = "registration" | "generation" | "admin_adjustment";
|
||||
|
||||
export type CreditSummary = {
|
||||
"available_balance": number;
|
||||
"reserved_balance": number;
|
||||
};
|
||||
|
||||
export type CsrfHeaders = {
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type ErrorDetails = {
|
||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
"conflict_model_ids"?: Array<string>;
|
||||
"current_task_ref"?: string;
|
||||
"field_errors"?: Array<{
|
||||
"field": string;
|
||||
@@ -65,10 +290,11 @@ export type ErrorDetails = {
|
||||
|
||||
export type ErrorEnvelope = {
|
||||
"error": {
|
||||
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
||||
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
|
||||
"correlation_id": string;
|
||||
"details": {
|
||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
"conflict_model_ids"?: Array<string>;
|
||||
"current_task_ref"?: string;
|
||||
"field_errors"?: Array<{
|
||||
"field": string;
|
||||
@@ -87,8 +313,179 @@ export type ErrorEnvelope = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportFormat = "jpg" | "png";
|
||||
|
||||
export type FailedEmptyTrashRequest = {
|
||||
"project_ids": Array<ProjectId>;
|
||||
};
|
||||
|
||||
export type FailedEmptyTrashResponse = {
|
||||
"ignored_project_ids": Array<ProjectId>;
|
||||
"trashed_project_ids": Array<ProjectId>;
|
||||
};
|
||||
|
||||
export type GenerationCreateHeaders = {
|
||||
"idempotency-key": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type GenerationCreateResponse = {
|
||||
"created": boolean;
|
||||
"task": GenerationTaskResponse;
|
||||
};
|
||||
|
||||
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
|
||||
|
||||
export type GenerationMultipartBody = {
|
||||
"client_submission_id": string;
|
||||
"confirmed_credit_cost": string;
|
||||
"creation_mode": "new_project" | "existing_project";
|
||||
"existing_reference_asset_ids"?: string;
|
||||
"model_config_version": string;
|
||||
"model_id": string;
|
||||
"project_id"?: string;
|
||||
"prompt": string;
|
||||
"ratio": ProjectRatio;
|
||||
"reference_files"?: Array<string>;
|
||||
"reference_manifest"?: string;
|
||||
};
|
||||
|
||||
export type GenerationParams = {
|
||||
"generationId": string;
|
||||
};
|
||||
|
||||
export type GenerationProjectItem = {
|
||||
"created_at": string;
|
||||
"error_category": GenerationErrorCategory | null;
|
||||
"generation_id": string;
|
||||
"prompt": string;
|
||||
"ratio": ProjectRatio;
|
||||
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type GenerationTaskResponse = {
|
||||
"confirmed_credit_cost": number;
|
||||
"created_at": string;
|
||||
"error_category": GenerationErrorCategory | null;
|
||||
"generation_id": string;
|
||||
"model_config_version": number;
|
||||
"model_id": string;
|
||||
"project_id": ProjectId;
|
||||
"prompt": string;
|
||||
"ratio": ProjectRatio;
|
||||
"reference_asset_ids": Array<string>;
|
||||
"reference_count": number;
|
||||
"reserved_credits": number;
|
||||
"status": GenerationTaskStatus;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type GenerationTaskStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||
|
||||
export type LatestExportItem = {
|
||||
"byte_size": number;
|
||||
"created_at": string;
|
||||
"download_url": string;
|
||||
"export_id": string;
|
||||
"format": ExportFormat;
|
||||
"pixel_height": number;
|
||||
"pixel_width": number;
|
||||
"sha256": string;
|
||||
"state_version": number;
|
||||
};
|
||||
|
||||
export type LatestExportMultipartBody = {
|
||||
"byte_size": string;
|
||||
"export_file": string;
|
||||
"export_id": string;
|
||||
"format": ExportFormat;
|
||||
"pixel_height": string;
|
||||
"pixel_width": string;
|
||||
"sha256": string;
|
||||
"state_version": string;
|
||||
};
|
||||
|
||||
export type LatestExportParams = {
|
||||
"format": ExportFormat;
|
||||
"projectId": ProjectId;
|
||||
};
|
||||
|
||||
export type LatestExportSaveResponse = {
|
||||
"byte_size": number;
|
||||
"created_at": string;
|
||||
"download_url": string;
|
||||
"export_id": string;
|
||||
"format": ExportFormat;
|
||||
"pixel_height": number;
|
||||
"pixel_width": number;
|
||||
"sha256": string;
|
||||
"state_version": number;
|
||||
"status": "saved";
|
||||
};
|
||||
|
||||
export type LoginCompleteRequest = {
|
||||
"registration_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type LoginCompleteResponse = {
|
||||
"audience": "user";
|
||||
"credits": CreditSummary;
|
||||
"session_expires_at": string;
|
||||
"status": "authenticated";
|
||||
"user": AuthenticatedUser;
|
||||
};
|
||||
|
||||
export type LoginSendRequest = {
|
||||
"email": string;
|
||||
};
|
||||
|
||||
export type LogoutHeaders = {
|
||||
"idempotency-key": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type LogoutResponse = {
|
||||
"status": "logged_out";
|
||||
};
|
||||
|
||||
export type ModelConfig = {
|
||||
"config_version": number;
|
||||
"contract_evidence_ref": string | null;
|
||||
"contract_validation_status": ModelContractValidationStatus;
|
||||
"credit_cost": number;
|
||||
"display_name": string;
|
||||
"enabled": boolean;
|
||||
"error_mapping_profile": Record<string, string>;
|
||||
"gateway_account_ref": string;
|
||||
"is_default": boolean;
|
||||
"model_id": ModelId;
|
||||
"prompt_max_length": number;
|
||||
"recommendation_priority": number;
|
||||
"reference_limits": ModelReferenceLimits;
|
||||
"route_profile": Record<string, unknown>;
|
||||
"runtime_availability": ModelRuntimeAvailability;
|
||||
"safety_source": string;
|
||||
"supported_ratios": Array<string>;
|
||||
};
|
||||
|
||||
export type ModelConfigCandidate = {
|
||||
"credit_cost": number;
|
||||
"display_name": string;
|
||||
"enabled": boolean;
|
||||
"error_mapping_profile": Record<string, string>;
|
||||
"gateway_account_ref": string;
|
||||
"is_default": boolean;
|
||||
"model_id": ModelId;
|
||||
"prompt_max_length": number;
|
||||
"recommendation_priority": number;
|
||||
"reference_limits": ModelReferenceLimits;
|
||||
"route_profile": Record<string, unknown>;
|
||||
"safety_source": string;
|
||||
"supported_ratios": Array<string>;
|
||||
};
|
||||
|
||||
export type ModelConfigSseEvent = {
|
||||
"config_set_version": number;
|
||||
"entity_ref": string;
|
||||
@@ -97,6 +494,45 @@ export type ModelConfigSseEvent = {
|
||||
"occurred_at": string;
|
||||
};
|
||||
|
||||
export type ModelConfigUpdateHeaders = {
|
||||
"idempotency-key": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type ModelConfigUpdateRequest = {
|
||||
"expected_config_set_version": number;
|
||||
"models": Array<ModelConfigCandidate>;
|
||||
};
|
||||
|
||||
export type ModelConfigurationResponse = {
|
||||
"config_set_version": number;
|
||||
"configured_default_model_id": ModelId;
|
||||
"models": Array<ModelConfig>;
|
||||
"recommended_model_id": ModelId | null;
|
||||
};
|
||||
|
||||
export type ModelContractValidationStatus = "blocked" | "unverified" | "verified";
|
||||
|
||||
export type ModelId = string;
|
||||
|
||||
export type ModelParams = {
|
||||
"model_id": ModelId;
|
||||
};
|
||||
|
||||
export type ModelReferenceLimits = {
|
||||
"max_file_bytes": number;
|
||||
"max_files": number;
|
||||
"max_total_bytes": number;
|
||||
};
|
||||
|
||||
export type ModelRuntimeAvailability = {
|
||||
"available_for_new_jobs": boolean;
|
||||
"checked_at": string;
|
||||
"reason": ModelRuntimeReason;
|
||||
};
|
||||
|
||||
export type ModelRuntimeReason = "available" | "configured_disabled" | "contract_unverified" | "contract_blocked" | "gateway_balance_insufficient" | "gateway_paused" | "worker_degraded";
|
||||
|
||||
export type ModelRuntimeSseEvent = {
|
||||
"entity_ref": string;
|
||||
"event_id": number;
|
||||
@@ -105,6 +541,191 @@ export type ModelRuntimeSseEvent = {
|
||||
"runtime_availability_version": number;
|
||||
};
|
||||
|
||||
export type ProjectDetailResponse = {
|
||||
"canvas_state": CanvasState;
|
||||
"created_at": string;
|
||||
"current_image_id": ProjectId | null;
|
||||
"deleted_at": string | null;
|
||||
"draft_prompt": string;
|
||||
"generations": Array<GenerationProjectItem>;
|
||||
"images": Array<ProjectImageItem>;
|
||||
"latest_exports": Array<LatestExportItem>;
|
||||
"name": string;
|
||||
"pixel_height": number;
|
||||
"pixel_width": number;
|
||||
"project_id": ProjectId;
|
||||
"purge_at": string | null;
|
||||
"ratio": ProjectRatio;
|
||||
"save_status": "saved";
|
||||
"state_version": number;
|
||||
"status": ProjectViewStatus;
|
||||
"successful_image_count": number;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type ProjectEditableState = {
|
||||
"canvas_state": CanvasState;
|
||||
"name": string;
|
||||
};
|
||||
|
||||
export type ProjectId = string;
|
||||
|
||||
export type ProjectImageItem = {
|
||||
"created_at": string;
|
||||
"generation_id": string;
|
||||
"image_id": ProjectId;
|
||||
};
|
||||
|
||||
export type ProjectImageParams = {
|
||||
"imageId": ProjectId;
|
||||
"projectId": ProjectId;
|
||||
};
|
||||
|
||||
export type ProjectListQuery = {
|
||||
"status"?: "active" | "trashed";
|
||||
};
|
||||
|
||||
export type ProjectListResponse = {
|
||||
"active_count": number;
|
||||
"active_limit": 20;
|
||||
"projects": Array<ProjectSummary>;
|
||||
};
|
||||
|
||||
export type ProjectParams = {
|
||||
"projectId": ProjectId;
|
||||
};
|
||||
|
||||
export type ProjectPurgeResponse = {
|
||||
"project_id": ProjectId;
|
||||
"status": "purged";
|
||||
};
|
||||
|
||||
export type ProjectRatio = "3:4" | "1:1" | "4:3" | "9:16";
|
||||
|
||||
export type ProjectRenameRequest = {
|
||||
"name": string;
|
||||
};
|
||||
|
||||
export type ProjectRenameResponse = {
|
||||
"name": string;
|
||||
"state_version": number;
|
||||
"status": "renamed";
|
||||
};
|
||||
|
||||
export type ProjectRestoreResponse = {
|
||||
"deleted_at": null;
|
||||
"project_id": ProjectId;
|
||||
"purge_at": null;
|
||||
"status": "active";
|
||||
};
|
||||
|
||||
export type ProjectStateConflictResponse = {
|
||||
"latest_state_version": number;
|
||||
"save_status": "conflicted";
|
||||
};
|
||||
|
||||
export type ProjectStateSaveHeaders = {
|
||||
"idempotency-key": string;
|
||||
"if-match": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type ProjectStateSaveResponse = {
|
||||
"save_status": "saved";
|
||||
"state_version": number;
|
||||
};
|
||||
|
||||
export type ProjectSummary = {
|
||||
"current_image_id": ProjectId | null;
|
||||
"deleted_at": string | null;
|
||||
"name": string;
|
||||
"project_id": ProjectId;
|
||||
"purge_at": string | null;
|
||||
"ratio": ProjectRatio;
|
||||
"state_version": number;
|
||||
"status": ProjectViewStatus;
|
||||
"successful_image_count": number;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type ProjectTrashResponse = {
|
||||
"deleted_at": string;
|
||||
"project_id": ProjectId;
|
||||
"purge_at": string;
|
||||
"status": "trashed";
|
||||
};
|
||||
|
||||
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
|
||||
|
||||
export type RecentAssetItem = {
|
||||
"asset_id": string;
|
||||
"asset_kind": RecentAssetKind;
|
||||
"resource_version": string;
|
||||
};
|
||||
|
||||
export type RecentAssetKind = "static_sticker" | "text_template";
|
||||
|
||||
export type RecentAssetListResponse = {
|
||||
"items": Array<RecentAssetItem>;
|
||||
};
|
||||
|
||||
export type RecentAssetQuery = {
|
||||
"asset_kind": RecentAssetKind;
|
||||
};
|
||||
|
||||
export type RecentAssetRecordRequest = {
|
||||
"asset_id": string;
|
||||
"asset_kind": RecentAssetKind;
|
||||
"resource_version": string;
|
||||
};
|
||||
|
||||
export type RecentAssetRecordResponse = {
|
||||
"status": "recorded";
|
||||
};
|
||||
|
||||
export type RegistrationCompleteHeaders = {
|
||||
"idempotency-key": string;
|
||||
};
|
||||
|
||||
export type RegistrationCompleteRequest = {
|
||||
"creator_name": string;
|
||||
"privacy_consent_accepted": boolean;
|
||||
"privacy_notice_version": string;
|
||||
"registration_id": string;
|
||||
"social_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type RegistrationCompleteResponse = {
|
||||
"credits": CreditSummary;
|
||||
"session_expires_at": string;
|
||||
"status": "registered";
|
||||
"user": AuthenticatedUser;
|
||||
};
|
||||
|
||||
export type RegistrationSendRequest = {
|
||||
"email": string;
|
||||
"invite_code": string;
|
||||
};
|
||||
|
||||
export type RegistrationSendResponse = {
|
||||
"challenge_expires_at": string;
|
||||
"registration_id": string;
|
||||
"resend_available_at": string;
|
||||
"status": "verification_sent";
|
||||
};
|
||||
|
||||
export type ReverseGeocodeRequest = {
|
||||
"latitude": number;
|
||||
"longitude": number;
|
||||
};
|
||||
|
||||
export type ReverseGeocodeResponse = {
|
||||
"formatted_value": string;
|
||||
"service_mode": "mock";
|
||||
"status": "resolved";
|
||||
};
|
||||
|
||||
export type SseEvent = {
|
||||
"entity_ref": string;
|
||||
"event_id": number;
|
||||
@@ -125,7 +746,7 @@ export type SseEvent = {
|
||||
"runtime_availability_version": number;
|
||||
};
|
||||
|
||||
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
||||
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
|
||||
|
||||
export type StateSseEvent = {
|
||||
"entity_ref": string;
|
||||
@@ -134,3 +755,12 @@ export type StateSseEvent = {
|
||||
"occurred_at": string;
|
||||
"state_version": number;
|
||||
};
|
||||
|
||||
export type UserSessionResponse = {
|
||||
"audience": "user";
|
||||
"authenticated": true;
|
||||
"credits": CreditSummary;
|
||||
"csrf_token": string;
|
||||
"expires_at": string;
|
||||
"user": AuthenticatedUser;
|
||||
};
|
||||
|
||||
+39
-6
@@ -1,8 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { ToolchainProbe } from "./toolchain-probe.js";
|
||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||
import { AdminAuthPage } from "./admin-auth.js";
|
||||
import { UserAuthPage } from "./user-auth.js";
|
||||
import { AccountSettingsPage } from "./account-settings.js";
|
||||
import { AdminUsersPage } from "./admin-users.js";
|
||||
import { AdminModelsPage } from "./admin-models.js";
|
||||
import { CreditsPage } from "./credits-page.js";
|
||||
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||
import { EditorPage } from "./editor-page.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
@@ -13,8 +20,34 @@ if (!root) {
|
||||
// Cache failure leaves public assets network-backed and must not create alternate persistence.
|
||||
void registerPublicAssetServiceWorker().catch(() => undefined);
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ToolchainProbe />
|
||||
</StrictMode>,
|
||||
);
|
||||
const appRoot = createRoot(root);
|
||||
let authRevision = 0;
|
||||
|
||||
function renderAuthenticationEntry() {
|
||||
authRevision += 1;
|
||||
const editorProject = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})\/editor$/i);
|
||||
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
|
||||
let authenticationPage;
|
||||
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/app/credits") authenticationPage = <CreditsPage key={authRevision} />;
|
||||
else if (editorProject?.[1]) authenticationPage = <EditorPage key={authRevision} projectId={editorProject[1]} />;
|
||||
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
|
||||
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
|
||||
else if (window.location.pathname === "/admin/users") authenticationPage = <AdminUsersPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/admin/models") authenticationPage = <AdminModelsPage key={authRevision} />;
|
||||
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
|
||||
else authenticationPage = <UserAuthPage key={authRevision} />;
|
||||
appRoot.render(
|
||||
<StrictMode>
|
||||
{authenticationPage}
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
// Any authenticated surface can dispatch this after a revoked/invalid session response.
|
||||
window.addEventListener("dada:session-invalid", () => {
|
||||
if (window.location.pathname.startsWith("/app")) window.history.replaceState(null, "", "/");
|
||||
renderAuthenticationEntry();
|
||||
});
|
||||
renderAuthenticationEntry();
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { MMCQ } from "@vibrant/quantizer-mmcq";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
import { P0A_COLOR_CARD_DEFINITIONS, type ColorCardDefinition } from "@dada/asset-renderer";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type { ColorCardDefinition } from "@dada/asset-renderer";
|
||||
|
||||
export interface PaletteColor {
|
||||
hex: string;
|
||||
population: number;
|
||||
rgbValue: number;
|
||||
}
|
||||
|
||||
export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = P0A_COLOR_CARD_DEFINITIONS;
|
||||
|
||||
// Coordinates are translated from the archived 320x320 renderer around its 160px center.
|
||||
export const COLOR_CARD_SOURCE_GEOMETRY = {
|
||||
style_01: {
|
||||
bounds: { bottom: 76, left: -26, right: 26, top: -74 },
|
||||
palette: { bottom: 37, left: -23, right: 23, top: -71 },
|
||||
},
|
||||
style_02: { bounds: { bottom: 69, left: -18, right: 18, top: -77 } },
|
||||
style_08: { bounds: { bottom: 10, left: -73, right: 73, top: -9 } },
|
||||
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||
} as const;
|
||||
|
||||
function normalizedHex(value: string) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
export function extractMmcqPalette(pixels: Uint8ClampedArray): PaletteColor[] {
|
||||
const swatches = MMCQ(pixels, { colorCount: 10 });
|
||||
if (swatches.length < 5) throw new Error("palette_five_colors_required");
|
||||
return swatches
|
||||
.map((swatch) => ({
|
||||
hex: normalizedHex(swatch.hex),
|
||||
population: swatch.population,
|
||||
rgbValue: (Math.round(swatch.r) << 16) + (Math.round(swatch.g) << 8) + Math.round(swatch.b),
|
||||
}))
|
||||
.sort((left, right) => right.population - left.population || left.rgbValue - right.rgbValue)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export function extractPaletteFromImage(image: CanvasImageSource & { height: number; width: number }) {
|
||||
const longestEdge = Math.max(image.width, image.height);
|
||||
if (longestEdge <= 0) throw new Error("palette_image_invalid");
|
||||
const scale = Math.min(1, 256 / longestEdge);
|
||||
const width = Math.max(1, Math.round(image.width * scale));
|
||||
const height = Math.max(1, Math.round(image.height * scale));
|
||||
const surface = document.createElement("canvas");
|
||||
surface.width = width;
|
||||
surface.height = height;
|
||||
const context = surface.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("palette_canvas_unavailable");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return extractMmcqPalette(context.getImageData(0, 0, width, height).data).map((entry) => entry.hex);
|
||||
}
|
||||
|
||||
function validatePalette(palette: readonly string[]) {
|
||||
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||
return palette.map(normalizedHex);
|
||||
}
|
||||
|
||||
export function createColorCardElement(
|
||||
definition: ColorCardDefinition,
|
||||
palette: readonly string[],
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
): CanvasElement {
|
||||
return {
|
||||
colors: validatePalette(palette),
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
opacity: 1,
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resource_version: "wp4-provider-v1",
|
||||
rotation: 0,
|
||||
scale: { x: 1, y: 1 },
|
||||
style_id: definition.styleId,
|
||||
style_parameters: {
|
||||
mapping_status: definition.mappingStatus,
|
||||
palette_algorithm_version: "mmcq-v1",
|
||||
renderer_name: definition.rendererName,
|
||||
},
|
||||
template_or_asset_id: definition.cardId,
|
||||
type: "color_card",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshColorCards(state: CanvasState, palette: readonly string[]): CanvasState {
|
||||
const colors = validatePalette(palette);
|
||||
const next = structuredClone(state);
|
||||
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...colors] } : element);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function drawColorCard(context: CanvasRenderingContext2D, element: CanvasElement) {
|
||||
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
if (element.style_id === "style_01") {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(-26, -74, 52, 145);
|
||||
colors.forEach((color, index) => {
|
||||
const top = -71 + index * 21.6;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-23, top, 46, index === colors.length - 1 ? 37 - top : 21.6);
|
||||
});
|
||||
context.fillStyle = "#222222";
|
||||
context.font = "700 5px Arial, sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillText("COLOR", -20, 44);
|
||||
context.fillStyle = "#979797";
|
||||
context.fillRect(-20, 54, 24, 1);
|
||||
context.fillStyle = "#ffffff";
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const left = -26 + index * 6.5;
|
||||
context.beginPath();
|
||||
context.moveTo(left, 71);
|
||||
context.lineTo(left + 3.25, 76);
|
||||
context.lineTo(left + 6.5, 71);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_02") {
|
||||
context.font = "4px Arial, sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "#222222";
|
||||
context.fillText("C O L O R", -17, -77);
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-18, -71 + index * 28, 36, 27);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_08") {
|
||||
colors.forEach((color, index) => {
|
||||
const left = -73 + index * 29.2;
|
||||
const center = -59 + index * 29;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(left, -2, index === colors.length - 1 ? 73 - left : 29.2, 4);
|
||||
context.beginPath();
|
||||
context.arc(center, -7, 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 1;
|
||||
context.stroke();
|
||||
context.fillStyle = "#222222";
|
||||
context.font = "4px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(`0${index + 1}`, center, 8);
|
||||
});
|
||||
return;
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.moveTo(-78, -9);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const y = -9 + index * 4.5;
|
||||
context.lineTo(-74, y + 2.25);
|
||||
context.lineTo(-78, y + 4.5);
|
||||
}
|
||||
context.lineTo(-70, 9);
|
||||
context.lineTo(-70, -9);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.fillStyle = "#222222";
|
||||
context.fillRect(-72, -9, 17, 18);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "700 5px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("C", -63.5, 0);
|
||||
const stripColors = [...colors, "#f2f2f2"];
|
||||
stripColors.forEach((color, index) => {
|
||||
const left = -55 + index * (133 / stripColors.length);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(left, -9, index === stripColors.length - 1 ? 78 - left : 133 / stripColors.length, 18);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ProjectEditableState } from "@dada/shared-contracts";
|
||||
|
||||
export type ProjectSaveStatus = "dirty" | "saving" | "saved" | "failed" | "conflicted";
|
||||
|
||||
export class ProjectStateConflict extends Error {
|
||||
readonly latestStateVersion: number;
|
||||
|
||||
constructor(latestStateVersion: number) {
|
||||
super("project_state_conflict");
|
||||
this.latestStateVersion = latestStateVersion;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictExportGuard {
|
||||
used = false;
|
||||
|
||||
async run(action: () => Promise<void>) {
|
||||
if (this.used) return false;
|
||||
this.used = true;
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionHistory<T> {
|
||||
private current: T;
|
||||
private readonly past: T[] = [];
|
||||
private readonly future: T[] = [];
|
||||
|
||||
constructor(initial: T) {
|
||||
this.current = structuredClone(initial);
|
||||
}
|
||||
|
||||
get canRedo() { return this.future.length > 0; }
|
||||
get canUndo() { return this.past.length > 0; }
|
||||
get value() { return structuredClone(this.current); }
|
||||
|
||||
commit(next: T) {
|
||||
this.past.push(structuredClone(this.current));
|
||||
this.current = structuredClone(next);
|
||||
this.future.length = 0;
|
||||
}
|
||||
|
||||
undo() {
|
||||
const previous = this.past.pop();
|
||||
if (previous === undefined) return undefined;
|
||||
this.future.push(structuredClone(this.current));
|
||||
this.current = previous;
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
redo() {
|
||||
const next = this.future.pop();
|
||||
if (next === undefined) return undefined;
|
||||
this.past.push(structuredClone(this.current));
|
||||
this.current = next;
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingSave {
|
||||
operationId: string;
|
||||
snapshot: ProjectEditableState;
|
||||
}
|
||||
|
||||
export class ProjectAutoSaveQueue {
|
||||
status: ProjectSaveStatus = "saved";
|
||||
stateVersion: number;
|
||||
conflictVersion?: number;
|
||||
|
||||
private disposed = false;
|
||||
private inFlight: Promise<boolean> | undefined;
|
||||
private lastSaved: ProjectEditableState;
|
||||
private pending: PendingSave | undefined;
|
||||
private retryIndex = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly debounceMs: number;
|
||||
private readonly onConflict: ((latestVersion: number) => void) | undefined;
|
||||
private readonly onSaved: ((snapshot: ProjectEditableState, stateVersion: number) => void) | undefined;
|
||||
private readonly onStatus: ((status: ProjectSaveStatus) => void) | undefined;
|
||||
private readonly retryDelaysMs: number[];
|
||||
private readonly save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
||||
|
||||
constructor(input: {
|
||||
debounceMs?: number;
|
||||
initialState: ProjectEditableState;
|
||||
initialVersion: number;
|
||||
onConflict?: (latestVersion: number) => void;
|
||||
onSaved?: (snapshot: ProjectEditableState, stateVersion: number) => void;
|
||||
onStatus?: (status: ProjectSaveStatus) => void;
|
||||
retryDelaysMs?: number[];
|
||||
save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
||||
}) {
|
||||
this.debounceMs = input.debounceMs ?? 1_000;
|
||||
this.lastSaved = structuredClone(input.initialState);
|
||||
this.onConflict = input.onConflict;
|
||||
this.onSaved = input.onSaved;
|
||||
this.onStatus = input.onStatus;
|
||||
this.retryDelaysMs = input.retryDelaysMs ?? [1_000, 2_000, 4_000, 8_000, 15_000];
|
||||
this.save = input.save;
|
||||
this.stateVersion = input.initialVersion;
|
||||
}
|
||||
|
||||
commit(snapshot: ProjectEditableState) {
|
||||
if (this.disposed || this.status === "conflicted") return;
|
||||
this.pending = { operationId: crypto.randomUUID(), snapshot: structuredClone(snapshot) };
|
||||
if (!this.inFlight) {
|
||||
this.setStatus("dirty");
|
||||
this.schedule(this.debounceMs);
|
||||
}
|
||||
}
|
||||
|
||||
async saveNow() {
|
||||
if (this.disposed || (this.status as ProjectSaveStatus) === "conflicted") return false;
|
||||
this.clearTimer();
|
||||
if (this.inFlight) await this.inFlight;
|
||||
if (this.disposed || this.status === "conflicted") return false;
|
||||
if (!this.pending) return this.status === "saved";
|
||||
return this.startSave();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private schedule(delay: number) {
|
||||
this.clearTimer();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
void this.startSave();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private startSave() {
|
||||
if (this.inFlight || !this.pending || this.disposed || this.status === "conflicted") {
|
||||
return this.inFlight ?? Promise.resolve(false);
|
||||
}
|
||||
const request = this.pending;
|
||||
this.pending = undefined;
|
||||
this.setStatus("saving");
|
||||
const attempt = this.save(structuredClone(request.snapshot), this.stateVersion, request.operationId)
|
||||
.then((result) => {
|
||||
this.stateVersion = result.stateVersion;
|
||||
this.lastSaved = structuredClone(request.snapshot);
|
||||
this.retryIndex = 0;
|
||||
this.onSaved?.(structuredClone(request.snapshot), this.stateVersion);
|
||||
if (this.pending) {
|
||||
this.setStatus("dirty");
|
||||
this.schedule(this.debounceMs);
|
||||
} else {
|
||||
this.setStatus("saved");
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ProjectStateConflict) {
|
||||
this.pending = undefined;
|
||||
this.conflictVersion = error.latestStateVersion;
|
||||
this.setStatus("conflicted");
|
||||
this.onConflict?.(error.latestStateVersion);
|
||||
return false;
|
||||
}
|
||||
if (!this.pending) this.pending = request;
|
||||
this.setStatus("failed");
|
||||
const delay = this.retryDelaysMs[Math.min(this.retryIndex, this.retryDelaysMs.length - 1)] ?? 15_000;
|
||||
this.retryIndex += 1;
|
||||
this.schedule(delay);
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.inFlight === attempt) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = attempt;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private setStatus(status: ProjectSaveStatus) {
|
||||
this.status = status;
|
||||
this.onStatus?.(status);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
getVirtualStickerWindow,
|
||||
staticStickerOriginalUrl,
|
||||
staticStickerThumbnailUrl,
|
||||
type StaticStickerCatalogItem,
|
||||
type VirtualStickerWindow,
|
||||
} from "@dada/static-sticker-catalog";
|
||||
|
||||
const resourceVersion = "fixture-v1";
|
||||
const partCounts = [203, 36, 27, 48, 38, 75, 37, 67, 48, 24, 40, 30, 27, 51, 62, 19, 36, 45, 92, 53, 69, 31, 36, 30, 183] as const;
|
||||
|
||||
function buildCatalog(): StaticStickerCatalogItem[] {
|
||||
let index = 0;
|
||||
return partCounts.flatMap((count, partIndex) => Array.from({ length: count }, (_, orderIndex) => {
|
||||
index += 1;
|
||||
const stableId = `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`;
|
||||
const item = {
|
||||
enabled: true,
|
||||
height: 0,
|
||||
mime: "image/png" as const,
|
||||
mime_type: "image/png" as const,
|
||||
order: orderIndex + 1,
|
||||
original_filename: `${stableId}.png`,
|
||||
original_reference: "",
|
||||
origin: "bundled_read_only" as const,
|
||||
part: partIndex + 1,
|
||||
relative_path: `sticker_part${partIndex + 1}/${stableId}.png`,
|
||||
resource_version: resourceVersion,
|
||||
sha256: "",
|
||||
stable_id: stableId,
|
||||
thumbnail_reference: { media: "thumbnail" as const, resource_id: stableId, resource_version: resourceVersion, url: "" },
|
||||
width: 0,
|
||||
} satisfies StaticStickerCatalogItem;
|
||||
item.original_reference = staticStickerOriginalUrl(item);
|
||||
item.thumbnail_reference.url = staticStickerThumbnailUrl(item);
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
export const P0A_STATIC_STICKER_CATALOG = buildCatalog();
|
||||
export const P0A_STATIC_STICKER_COUNT = P0A_STATIC_STICKER_CATALOG.length;
|
||||
|
||||
export function stickerWindow(items: readonly StaticStickerCatalogItem[], scrollTop: number, viewportHeight: number): VirtualStickerWindow<StaticStickerCatalogItem> {
|
||||
return getVirtualStickerWindow(items, { columns: 2, itemHeight: 144, overscanScreens: 2, scrollTop, viewportHeight });
|
||||
}
|
||||
|
||||
export { staticStickerOriginalUrl, staticStickerThumbnailUrl };
|
||||
@@ -0,0 +1,287 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
import { P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
|
||||
export type TextAlign = "center" | "left" | "right";
|
||||
|
||||
export interface TextTemplateDefinition {
|
||||
available: boolean;
|
||||
catalogOrder: number;
|
||||
category: TextTemplateCategory;
|
||||
defaultFontId: string;
|
||||
defaultFontSize: number;
|
||||
defaultText: string;
|
||||
displayName: string;
|
||||
fontUrl?: string;
|
||||
resourceClass: "parameter_only" | "zip_template";
|
||||
resourceVersion: string;
|
||||
templateId: string;
|
||||
}
|
||||
|
||||
export interface FontOption {
|
||||
displayName: string;
|
||||
fontId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TextStylePatch {
|
||||
backgroundColor?: string;
|
||||
backgroundEnabled?: boolean;
|
||||
backgroundOpacity?: number;
|
||||
fillColor?: string;
|
||||
fontOverride?: string | null;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
strokeColor?: string;
|
||||
strokeEnabled?: boolean;
|
||||
strokeWidth?: number;
|
||||
textAlign?: TextAlign;
|
||||
}
|
||||
|
||||
const fixtureVersion = "wp4-fixture-v1";
|
||||
const defaults = {
|
||||
background_color: "#FFE62C",
|
||||
background_enabled: false,
|
||||
background_opacity: 1,
|
||||
fill_color: "#111111",
|
||||
letter_spacing: 1,
|
||||
line_height: 1.2,
|
||||
stroke_color: "#000000",
|
||||
stroke_enabled: false,
|
||||
stroke_width: 0,
|
||||
text_align: "center",
|
||||
} as const;
|
||||
|
||||
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
|
||||
|
||||
const seeds: readonly CatalogSeed[] = [
|
||||
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
|
||||
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
|
||||
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
|
||||
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
|
||||
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
|
||||
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
|
||||
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
|
||||
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
|
||||
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
|
||||
["H002", "title", "30°C", "30°C", "H002_FONT"],
|
||||
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
|
||||
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
|
||||
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
|
||||
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
|
||||
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
|
||||
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
|
||||
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
|
||||
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
|
||||
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
|
||||
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
|
||||
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
|
||||
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
|
||||
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
|
||||
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
|
||||
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
|
||||
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
|
||||
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
|
||||
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
|
||||
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
|
||||
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
|
||||
];
|
||||
|
||||
const seedById = new Map(seeds.map((seed) => [seed[0], seed]));
|
||||
|
||||
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
||||
const seed = seedById.get(templateId);
|
||||
if (!seed) throw new Error(`missing text template definition ${templateId}`);
|
||||
return {
|
||||
available: seed[5] === true,
|
||||
catalogOrder,
|
||||
category: seed[1],
|
||||
defaultFontId: seed[4],
|
||||
defaultFontSize: 48,
|
||||
defaultText: seed[3],
|
||||
displayName: seed[2],
|
||||
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${fixtureVersion}/${seed[4]}` } : {}),
|
||||
resourceClass: seed[6] ?? "zip_template",
|
||||
resourceVersion: fixtureVersion,
|
||||
templateId,
|
||||
};
|
||||
});
|
||||
|
||||
const fontOptionDefinitions: Readonly<Record<typeof P0A_REQUIRED_FONT_PANEL_IDS[number], string>> = {
|
||||
FONT005: "Rammetto",
|
||||
FONT008: "正圆体",
|
||||
FONT011: "默陌手写",
|
||||
FONT021: "喜月体",
|
||||
FONT022: "素白体",
|
||||
FONT027: "锐正圆",
|
||||
FONT039: "字由油漆",
|
||||
FONT043: "喜脉体",
|
||||
FONT046: "可口可乐",
|
||||
FONT052: "Oraqle Script",
|
||||
FONT081: "Lexend Deca",
|
||||
};
|
||||
|
||||
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({
|
||||
displayName: fontOptionDefinitions[fontId],
|
||||
fontId,
|
||||
url: `/api/v1/assets/public/${fixtureVersion}/${fontId}`,
|
||||
}));
|
||||
|
||||
export function fontOption(fontId: string) {
|
||||
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
|
||||
}
|
||||
|
||||
export function fontIdForTextElement(element: CanvasElement) {
|
||||
const defaultFontId = element.style_parameters?.default_font_id;
|
||||
return element.font_override ?? (typeof defaultFontId === "string" ? defaultFontId : undefined);
|
||||
}
|
||||
|
||||
function cloneElement(element: CanvasElement): CanvasElement {
|
||||
return structuredClone(element);
|
||||
}
|
||||
|
||||
function templateById(templates: readonly TextTemplateDefinition[], templateId: string) {
|
||||
const template = templates.find((candidate) => candidate.templateId === templateId);
|
||||
if (!template) throw new Error("text_template_not_found");
|
||||
return template;
|
||||
}
|
||||
|
||||
function checkedColor(value: string) {
|
||||
if (!/^#[0-9A-F]{6}$/i.test(value)) throw new Error("text_color_invalid");
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
function isStep(value: number, minimum: number, step: number) {
|
||||
return Math.abs((value - minimum) / step - Math.round((value - minimum) / step)) < 1e-8;
|
||||
}
|
||||
|
||||
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
|
||||
return { ...defaults, default_font_id: template.defaultFontId };
|
||||
}
|
||||
|
||||
export function searchTextTemplates(
|
||||
templates: readonly TextTemplateDefinition[],
|
||||
input: { category?: TextTemplateCategory; query?: string },
|
||||
) {
|
||||
const query = input.query?.trim().toLocaleLowerCase("zh-CN") ?? "";
|
||||
return templates
|
||||
.filter((template) => !input.category || template.category === input.category)
|
||||
.filter((template) => !query || template.displayName.toLocaleLowerCase("zh-CN").includes(query))
|
||||
.toSorted((left, right) => left.catalogOrder - right.catalogOrder);
|
||||
}
|
||||
|
||||
export function createTextTemplateElement(
|
||||
template: TextTemplateDefinition,
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
transform: Partial<Pick<CanvasElement, "position" | "rotation" | "scale">> = {},
|
||||
): CanvasElement {
|
||||
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||
return {
|
||||
content: template.defaultText,
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
font_size: template.defaultFontSize,
|
||||
opacity: 1,
|
||||
position: transform.position ?? { x: 0.5, y: 0.5 },
|
||||
resource_version: template.resourceVersion,
|
||||
rotation: transform.rotation ?? 0,
|
||||
scale: transform.scale ?? { x: 1, y: 1 },
|
||||
style_parameters: templateStyle(template),
|
||||
template_or_asset_id: template.templateId,
|
||||
type: "text_template",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function effectiveFontSize(element: CanvasElement) {
|
||||
return (element.font_size ?? 48) * element.scale.x;
|
||||
}
|
||||
|
||||
export class TextEditSession {
|
||||
readonly original: CanvasElement;
|
||||
private draft: CanvasElement;
|
||||
private readonly templates: readonly TextTemplateDefinition[];
|
||||
|
||||
constructor(element: CanvasElement, templates: readonly TextTemplateDefinition[]) {
|
||||
if (element.type !== "text_template") throw new Error("text_element_required");
|
||||
this.original = cloneElement(element);
|
||||
this.draft = cloneElement(element);
|
||||
this.templates = templates;
|
||||
}
|
||||
|
||||
get value() { return cloneElement(this.draft); }
|
||||
|
||||
cancel() { return cloneElement(this.original); }
|
||||
|
||||
complete() {
|
||||
if (!(this.draft.content ?? "").trim()) throw new Error("text_content_required");
|
||||
return cloneElement(this.draft);
|
||||
}
|
||||
|
||||
setContent(content: string) {
|
||||
this.draft.content = content;
|
||||
}
|
||||
|
||||
setEffectiveFontSize(value: number) {
|
||||
if (!Number.isFinite(value) || value < 1 || value > 512) throw new Error("text_font_size_invalid");
|
||||
const base = this.draft.font_size ?? 48;
|
||||
const factor = value / base;
|
||||
this.draft.scale = { x: factor, y: factor };
|
||||
}
|
||||
|
||||
setStyle(patch: TextStylePatch) {
|
||||
const style = { ...(this.draft.style_parameters ?? {}) };
|
||||
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
|
||||
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
|
||||
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
|
||||
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
|
||||
if (patch.backgroundEnabled !== undefined) style.background_enabled = patch.backgroundEnabled;
|
||||
if (patch.strokeWidth !== undefined) {
|
||||
if (!Number.isFinite(patch.strokeWidth) || patch.strokeWidth < 0 || patch.strokeWidth > 12) throw new Error("text_stroke_width_invalid");
|
||||
style.stroke_width = patch.strokeWidth;
|
||||
}
|
||||
if (patch.backgroundOpacity !== undefined) {
|
||||
if (!Number.isFinite(patch.backgroundOpacity) || patch.backgroundOpacity < 0 || patch.backgroundOpacity > 1) throw new Error("text_background_opacity_invalid");
|
||||
style.background_opacity = patch.backgroundOpacity;
|
||||
}
|
||||
if (patch.textAlign !== undefined) {
|
||||
if (!["center", "left", "right"].includes(patch.textAlign)) throw new Error("text_align_invalid");
|
||||
style.text_align = patch.textAlign;
|
||||
}
|
||||
if (patch.lineHeight !== undefined) {
|
||||
if (patch.lineHeight < 1 || patch.lineHeight > 1.9 || !isStep(patch.lineHeight, 1, 0.1)) throw new Error("text_line_height_invalid");
|
||||
style.line_height = patch.lineHeight;
|
||||
}
|
||||
if (patch.letterSpacing !== undefined) {
|
||||
if (patch.letterSpacing < 1 || patch.letterSpacing > 20 || !isStep(patch.letterSpacing, 1, 1)) throw new Error("text_letter_spacing_invalid");
|
||||
style.letter_spacing = patch.letterSpacing;
|
||||
}
|
||||
if (patch.fontOverride !== undefined) {
|
||||
if (patch.fontOverride === null) delete this.draft.font_override;
|
||||
else this.draft.font_override = patch.fontOverride;
|
||||
}
|
||||
this.draft.style_parameters = style;
|
||||
}
|
||||
|
||||
switchTemplate(templateId: string) {
|
||||
const template = templateById(this.templates, templateId);
|
||||
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||
const content = this.draft.content;
|
||||
this.draft = {
|
||||
...this.draft,
|
||||
font_size: template.defaultFontSize,
|
||||
resource_version: template.resourceVersion,
|
||||
style_parameters: templateStyle(template),
|
||||
template_or_asset_id: template.templateId,
|
||||
};
|
||||
if (content !== undefined) this.draft.content = content;
|
||||
delete this.draft.font_override;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export type ArchivedFontStatus = "idle" | "loading" | "ready" | "unavailable";
|
||||
|
||||
interface LoadableFontFace {
|
||||
load: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface FontSetPort {
|
||||
add: (face: unknown) => unknown;
|
||||
check: (font: string) => boolean;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ArchivedFontReference {
|
||||
fontId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function fontFamilyName(fontId: string) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(fontId)) throw new Error("font_id_invalid");
|
||||
return `Dada_${fontId.replaceAll(/[^A-Za-z0-9_]/g, "_")}`;
|
||||
}
|
||||
|
||||
export class ArchivedFontLoader {
|
||||
private readonly createFace: (family: string, source: string) => LoadableFontFace;
|
||||
private readonly fontSet: FontSetPort;
|
||||
private readonly pending = new Map<string, Promise<ArchivedFontStatus>>();
|
||||
private readonly statuses = new Map<string, ArchivedFontStatus>();
|
||||
|
||||
constructor(input: { createFace: (family: string, source: string) => LoadableFontFace; fontSet: FontSetPort }) {
|
||||
this.createFace = input.createFace;
|
||||
this.fontSet = input.fontSet;
|
||||
}
|
||||
|
||||
status(fontId: string): ArchivedFontStatus {
|
||||
return this.statuses.get(fontId) ?? "idle";
|
||||
}
|
||||
|
||||
ensure(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
|
||||
const existing = this.pending.get(reference.fontId);
|
||||
if (existing) return existing;
|
||||
const operation = this.load(reference);
|
||||
this.pending.set(reference.fontId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
retry(reference: ArchivedFontReference) {
|
||||
this.pending.delete(reference.fontId);
|
||||
this.statuses.delete(reference.fontId);
|
||||
return this.ensure(reference);
|
||||
}
|
||||
|
||||
private async load(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
|
||||
const family = fontFamilyName(reference.fontId);
|
||||
this.statuses.set(reference.fontId, "loading");
|
||||
try {
|
||||
const face = this.createFace(family, `url("${reference.url}")`);
|
||||
const loaded = await face.load();
|
||||
this.fontSet.add(loaded);
|
||||
await this.fontSet.ready;
|
||||
if (!this.fontSet.check(`16px "${family}"`)) throw new Error("font_not_ready");
|
||||
this.statuses.set(reference.fontId, "ready");
|
||||
return "ready";
|
||||
} catch {
|
||||
this.statuses.set(reference.fontId, "unavailable");
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserArchivedFontLoader() {
|
||||
return new ArchivedFontLoader({
|
||||
createFace: (family, source) => new FontFace(family, source),
|
||||
fontSet: document.fonts as unknown as FontSetPort,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { effectiveFontSize, P0A_FONT_OPTIONS, type TextStylePatch, type TextTemplateDefinition } from "./text-assets.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
function scalar<T extends string | number | boolean>(element: CanvasElement, key: string, fallback: T): T {
|
||||
const value = element.style_parameters?.[key];
|
||||
return typeof value === typeof fallback ? value as T : fallback;
|
||||
}
|
||||
|
||||
function NumberAndRange(props: { label: string; max: number; min: number; onChange: (value: number) => void; step: number; value: number }) {
|
||||
return <label className="editor-range editor-text-number"><span>{props.label}<output>{props.value}</output></span><input aria-label={`${props.label}滑杆`} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="range" value={props.value} /><input aria-label={props.label} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="number" value={props.value} /></label>;
|
||||
}
|
||||
|
||||
export function TextInspector(props: {
|
||||
draft: CanvasElement;
|
||||
onCancel: () => void;
|
||||
onComplete: () => void;
|
||||
onContent: (content: string) => void;
|
||||
onFont: (fontId: string | null) => void;
|
||||
onFontSize: (value: number) => void;
|
||||
onStyle: (patch: TextStylePatch) => void;
|
||||
onTemplate: (templateId: string) => void;
|
||||
templates: readonly TextTemplateDefinition[];
|
||||
}) {
|
||||
const style = props.draft.style_parameters ?? {};
|
||||
const backgroundOpacity = Math.round(scalar(props.draft, "background_opacity", 1) * 100);
|
||||
const strokeEnabled = scalar(props.draft, "stroke_enabled", false);
|
||||
const backgroundEnabled = scalar(props.draft, "background_enabled", false);
|
||||
return <div className="editor-text-inspector">
|
||||
<label>文字模板切换<select aria-label="文字模板切换" onChange={(event) => props.onTemplate(event.target.value)} value={props.draft.template_or_asset_id}>
|
||||
{props.templates.map((template) => <option disabled={!template.available} key={template.templateId} value={template.templateId}>{template.templateId} · {template.displayName}{template.available ? "" : "(素材暂不可用)"}</option>)}
|
||||
</select></label>
|
||||
<label>文字内容<textarea aria-label="文字内容" onChange={(event) => props.onContent(event.target.value)} rows={4} value={props.draft.content ?? ""} /></label>
|
||||
<label>字体覆盖<select aria-label="字体覆盖" onChange={(event) => props.onFont(event.target.value || null)} value={props.draft.font_override ?? ""}><option value="">模板默认字体</option>{P0A_FONT_OPTIONS.map((font) => <option key={font.fontId} value={font.fontId}>{font.fontId} · {font.displayName}</option>)}</select></label>
|
||||
<NumberAndRange label="有效字号" max={256} min={1} onChange={props.onFontSize} step={1} value={Math.round(effectiveFontSize(props.draft))} />
|
||||
<fieldset className="editor-color-fields"><legend>文字颜色</legend>
|
||||
<label>文字填充色<input aria-label="文字填充色" onChange={(event) => props.onStyle({ fillColor: event.target.value })} type="color" value={scalar(props.draft, "fill_color", "#111111")} /></label>
|
||||
<div className="editor-color-swatches" role="group" aria-label="文字参考色"><button aria-label="红色填充" onClick={() => props.onStyle({ fillColor: "#FA5751" })} style={{ background: "#FA5751" }} type="button" /><button aria-label="黑色填充" onClick={() => props.onStyle({ fillColor: "#000000" })} style={{ background: "#000000" }} type="button" /></div>
|
||||
</fieldset>
|
||||
<label className="editor-check"><input aria-label="启用描边" checked={strokeEnabled} onChange={(event) => props.onStyle({ strokeEnabled: event.target.checked })} type="checkbox" />启用描边</label>
|
||||
<NumberAndRange label="描边宽度" max={12} min={0} onChange={(value) => props.onStyle({ strokeWidth: value })} step={1} value={scalar(props.draft, "stroke_width", 0)} />
|
||||
<label>描边颜色<input aria-label="描边颜色" onChange={(event) => props.onStyle({ strokeColor: event.target.value })} type="color" value={scalar(props.draft, "stroke_color", "#000000")} /></label>
|
||||
<label className="editor-check"><input aria-label="启用文字背景" checked={backgroundEnabled} onChange={(event) => props.onStyle({ backgroundEnabled: event.target.checked })} type="checkbox" />启用文字背景</label>
|
||||
<label>文字背景色<input aria-label="文字背景色" onChange={(event) => props.onStyle({ backgroundColor: event.target.value })} type="color" value={scalar(props.draft, "background_color", "#FFE62C")} /></label>
|
||||
<NumberAndRange label="背景透明度" max={100} min={0} onChange={(value) => props.onStyle({ backgroundOpacity: value / 100 })} step={1} value={backgroundOpacity} />
|
||||
<label>文字对齐<select aria-label="文字对齐" onChange={(event) => props.onStyle({ textAlign: event.target.value as "center" | "left" | "right" })} value={scalar(props.draft, "text_align", "center")}><option value="left">左对齐</option><option value="center">居中</option><option value="right">右对齐</option></select></label>
|
||||
<NumberAndRange label="行距" max={1.9} min={1} onChange={(value) => props.onStyle({ lineHeight: value })} step={0.1} value={scalar(props.draft, "line_height", 1.2)} />
|
||||
<NumberAndRange label="字距" max={20} min={1} onChange={(value) => props.onStyle({ letterSpacing: value })} step={1} value={scalar(props.draft, "letter_spacing", 1)} />
|
||||
<div className="editor-inspector-actions"><button onClick={props.onCancel} type="button">取消</button><button aria-label="完成文字编辑" className="editor-primary" onClick={props.onComplete} type="button">完成</button></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
||||
|
||||
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
||||
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
||||
];
|
||||
|
||||
export function TextTemplatePanel(props: {
|
||||
canAdd: boolean;
|
||||
category?: TextTemplateCategory;
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
onAdd: (template: TextTemplateDefinition) => void;
|
||||
onCategory: (category?: TextTemplateCategory) => void;
|
||||
onQuery: (query: string) => void;
|
||||
onRetry: () => void;
|
||||
query: string;
|
||||
recentIds: readonly string[];
|
||||
templates: readonly TextTemplateDefinition[];
|
||||
}) {
|
||||
const visible = searchTextTemplates(props.templates, { ...(props.category ? { category: props.category } : {}), query: props.query });
|
||||
const recent = props.recentIds.map((id) => props.templates.find((template) => template.templateId === id)).filter((item): item is TextTemplateDefinition => Boolean(item));
|
||||
const loadFailed = props.templates.some((template) => template.available && props.fontStatuses[template.defaultFontId] === "unavailable");
|
||||
return <section className="editor-text-assets">
|
||||
<h2>文字模板</h2>
|
||||
<input aria-label="搜索文字模板显示名称" className="editor-template-search" onChange={(event) => props.onQuery(event.target.value)} placeholder="搜索文字模板显示名称" type="search" value={props.query} />
|
||||
<div aria-label="文字模板分类" className="editor-template-categories" role="group">
|
||||
{categories.map((item) => <button aria-pressed={item.id === props.category || (!item.id && !props.category)} className={item.id === props.category || (!item.id && !props.category) ? "active" : ""} key={item.label} onClick={() => props.onCategory(item.id)} type="button">{item.label}</button>)}
|
||||
</div>
|
||||
{loadFailed ? <button className="editor-template-retry" onClick={props.onRetry} type="button">重试文字素材</button> : null}
|
||||
{recent.length > 0 ? <div aria-label="最近使用文字模板" className="editor-template-recent"><h3>最近使用</h3><div>{recent.map((template) => <span key={template.templateId}><strong>{template.templateId}</strong>{template.displayName}</span>)}</div></div> : null}
|
||||
<div className="editor-template-grid">
|
||||
{visible.map((template) => {
|
||||
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
|
||||
const unavailable = !template.available || status === "unavailable";
|
||||
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
|
||||
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
|
||||
<strong>{template.templateId}</strong>
|
||||
<span>{template.displayName}</span>
|
||||
{unavailable ? <small>素材暂不可用</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{visible.length === 0 ? <p className="editor-muted">没有匹配的文字模板</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
:root {
|
||||
color: #111111;
|
||||
background: #f6f6f4;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-synthesis: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
button {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 230px minmax(520px, 1fr) 48px;
|
||||
}
|
||||
|
||||
.auth-art {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 30%) 1fr;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
background: #eeeee9;
|
||||
}
|
||||
|
||||
.auth-wordmark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 48px;
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 96px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.auth-art-copy {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 54px;
|
||||
padding: 26px 54px 20px;
|
||||
}
|
||||
|
||||
.auth-art-copy span {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-art-copy strong {
|
||||
max-width: 520px;
|
||||
font-size: 27px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-art-block {
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
right: 16%;
|
||||
width: 31%;
|
||||
height: 92px;
|
||||
background: #b9bab4;
|
||||
}
|
||||
|
||||
.auth-art-line {
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
bottom: 38px;
|
||||
width: 48%;
|
||||
height: 48px;
|
||||
border-right: 1px solid #111111;
|
||||
border-bottom: 1px solid #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
position: relative;
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 32px 28px 64px;
|
||||
}
|
||||
|
||||
.auth-admin-link {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 30px;
|
||||
color: #333333;
|
||||
font-size: 13px;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(480px, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border: 1px solid #8a8a86;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
border: 0;
|
||||
color: #222222;
|
||||
background: #f6f6f4;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-tab + .auth-tab {
|
||||
border-left: 1px solid #8a8a86;
|
||||
}
|
||||
|
||||
.auth-tab[aria-selected="true"] {
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 350px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.auth-form h1 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 27px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.auth-registration-form {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
margin: 0 0 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-form input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #777773;
|
||||
border-radius: 0;
|
||||
padding: 0 13px;
|
||||
color: #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-form input:focus {
|
||||
border-color: #111111;
|
||||
outline: 2px solid #f2f500;
|
||||
}
|
||||
|
||||
.auth-code-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 142px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-code-row input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auth-secondary,
|
||||
.auth-primary {
|
||||
height: 46px;
|
||||
border: 1px solid #777773;
|
||||
border-radius: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-secondary {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-primary {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.auth-secondary:not(:disabled),
|
||||
.auth-primary:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-secondary:disabled,
|
||||
.auth-primary:disabled {
|
||||
color: #777773;
|
||||
background: #deded9;
|
||||
}
|
||||
|
||||
.auth-status,
|
||||
.auth-error {
|
||||
min-height: 20px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
color: #3c5b32;
|
||||
}
|
||||
|
||||
.auth-registration-status {
|
||||
align-self: flex-end;
|
||||
margin: -52px 0 14px;
|
||||
padding: 6px 10px;
|
||||
color: #f2f500;
|
||||
background: #111111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-verified-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-verified-grid > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-height: 64px;
|
||||
border: 1px solid #b4b4af;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-verified-grid strong {
|
||||
color: #2f6b45;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.auth-verified-grid span {
|
||||
overflow: hidden;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-text-action {
|
||||
align-self: flex-end;
|
||||
border: 0;
|
||||
padding: 4px 0;
|
||||
color: #075f8f;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-registration-form > .auth-text-action {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-consent {
|
||||
border: 1px solid #b4b4af;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-consent-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-checkbox {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-form .auth-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 1px 0 0;
|
||||
accent-color: #111111;
|
||||
}
|
||||
|
||||
.auth-consent p {
|
||||
margin: 8px 0 0 28px;
|
||||
color: #8d281b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.auth-dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 28px;
|
||||
background: rgba(17, 17, 17, 0.82);
|
||||
}
|
||||
|
||||
.auth-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
width: min(720px, 100%);
|
||||
max-height: min(760px, calc(100vh - 56px));
|
||||
border: 2px solid #111111;
|
||||
background: #f6f6f4;
|
||||
box-shadow: 12px 12px 0 #f2f500;
|
||||
}
|
||||
|
||||
.auth-dialog > header {
|
||||
border-bottom: 1px solid #111111;
|
||||
padding: 22px 24px 18px;
|
||||
}
|
||||
|
||||
.auth-dialog header p {
|
||||
margin: 0 0 6px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.auth-dialog-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 24px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 24px 18px;
|
||||
}
|
||||
|
||||
.auth-dialog-content section {
|
||||
border-bottom: 1px solid #ccccb9;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.auth-dialog-content h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.auth-dialog-content p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-dialog > footer {
|
||||
border-top: 1px solid #111111;
|
||||
padding: 0 24px 18px;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
border-left: 4px solid #c7432f;
|
||||
padding: 8px 10px;
|
||||
color: #8d281b;
|
||||
background: #fff0ed;
|
||||
}
|
||||
|
||||
.auth-local-notice {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 18px;
|
||||
color: #f2f500;
|
||||
background: #111111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.auth-page {
|
||||
grid-template-rows: 150px minmax(560px, 1fr) auto;
|
||||
}
|
||||
|
||||
.auth-art {
|
||||
grid-template-columns: 42% 58%;
|
||||
}
|
||||
|
||||
.auth-wordmark {
|
||||
padding: 0 18px;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.auth-art-copy {
|
||||
gap: 30px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.auth-art-copy span {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.auth-art-copy strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-art-block {
|
||||
top: 54px;
|
||||
right: 8%;
|
||||
width: 33%;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.auth-art-line {
|
||||
right: 2%;
|
||||
bottom: 20px;
|
||||
width: 44%;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
padding: 56px 20px 42px;
|
||||
}
|
||||
|
||||
.auth-admin-link {
|
||||
top: 22px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.auth-registration-status {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.auth-verified-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-dialog-backdrop {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auth-dialog {
|
||||
max-height: calc(100vh - 32px);
|
||||
box-shadow: 6px 6px 0 #f2f500;
|
||||
}
|
||||
|
||||
.auth-dialog-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.auth-code-row {
|
||||
grid-template-columns: minmax(0, 1fr) 126px;
|
||||
}
|
||||
|
||||
.auth-local-notice {
|
||||
min-height: 56px;
|
||||
padding-block: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.auth-code-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-secondary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { registrationNotice } from "@dada/shared-contracts";
|
||||
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
|
||||
import "./user-auth.css";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
type SendState = "idle" | "sending" | "sent" | "error";
|
||||
|
||||
interface ErrorEnvelopeBody {
|
||||
error?: {
|
||||
details?: { field_errors?: Array<{ message_key?: string }> };
|
||||
message_key?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const messageByKey: Record<string, string> = {
|
||||
"auth.account.suspended": "账号已暂停,请联系管理员。",
|
||||
"auth.challenge.expired": "验证码已过期,请重新获取。",
|
||||
"auth.challenge.invalid": "验证码不正确,请检查后重试。",
|
||||
"auth.challenge.resend_too_soon": "请等待倒计时结束后重新获取验证码。",
|
||||
"auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。",
|
||||
"auth.login.admin_required": "此邮箱需从管理员登录入口进入。",
|
||||
"auth.login.registration_required": "该邮箱尚未注册,请切换到注册。",
|
||||
"auth.email.already_registered": "该邮箱已注册,请切换到登录。",
|
||||
"auth.invite.disabled": "邀请码已停用,请更换邀请码。",
|
||||
"auth.invite.exhausted": "邀请码使用次数已耗尽,请更换邀请码。",
|
||||
"auth.invite.expired": "邀请码已过期,请更换邀请码。",
|
||||
"auth.invite.not_found": "邀请码无效,请检查后重试。",
|
||||
"auth.privacy.consent_required": "请阅读并同意《内测使用与隐私告知》。",
|
||||
"auth.privacy.notice_version_invalid": "告知版本已更新,请重新阅读后同意。",
|
||||
"auth.profile.invalid": "请检查创作署名和社交 ID。",
|
||||
"auth.registration.login_required": "该邮箱已注册,请切换到登录。",
|
||||
"auth.registration.stage_limit_reached": "本轮内测名额已满,请返回登录。",
|
||||
"auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。",
|
||||
};
|
||||
|
||||
function errorMessage(body: ErrorEnvelopeBody) {
|
||||
const key = body.error?.details?.field_errors?.[0]?.message_key ?? body.error?.message_key;
|
||||
return key ? (messageByKey[key] ?? "请求未完成,请检查后重试。") : "请求未完成,请检查后重试。";
|
||||
}
|
||||
|
||||
function maskedEmail(email: string) {
|
||||
const [local = "", domain = ""] = email.split("@", 2);
|
||||
const visible = local.slice(0, Math.min(2, local.length));
|
||||
return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`;
|
||||
}
|
||||
|
||||
function maskedRegistrationEmail(email: string) {
|
||||
const [local = "", domain = ""] = email.split("@", 2);
|
||||
return `${local.slice(0, 1)}***@${domain.slice(0, 1)}***`;
|
||||
}
|
||||
|
||||
function maskedInvite(inviteCode: string) {
|
||||
return `••••${inviteCode.slice(-3)}`;
|
||||
}
|
||||
|
||||
export function UserAuthPage() {
|
||||
const emailId = useId();
|
||||
const codeId = useId();
|
||||
const inviteId = useId();
|
||||
const creatorNameId = useId();
|
||||
const socialId = useId();
|
||||
const loginTab = useRef<HTMLButtonElement>(null);
|
||||
const registerTab = useRef<HTMLButtonElement>(null);
|
||||
const noticeButton = useRef<HTMLButtonElement>(null);
|
||||
const noticeCloseButton = useRef<HTMLButtonElement>(null);
|
||||
const noticeDialog = useRef<HTMLElement>(null);
|
||||
const [mode, setMode] = useState<AuthMode>("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [creatorName, setCreatorName] = useState("");
|
||||
const [socialHandle, setSocialHandle] = useState("");
|
||||
const [privacyConsentAccepted, setPrivacyConsentAccepted] = useState(false);
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const [registrationId, setRegistrationId] = useState<string>();
|
||||
const [sendState, setSendState] = useState<SendState>("idle");
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [error, setError] = useState<string>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||
const registrationReady = Boolean(
|
||||
registrationId
|
||||
&& /^[0-9]{6}$/.test(code)
|
||||
&& creatorName.trim()
|
||||
&& socialHandle.trim()
|
||||
&& privacyConsentAccepted,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noticeOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
noticeCloseButton.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [noticeOpen]);
|
||||
|
||||
function resetRegistrationDetails() {
|
||||
setCode("");
|
||||
setCreatorName("");
|
||||
setSocialHandle("");
|
||||
setPrivacyConsentAccepted(false);
|
||||
setNoticeOpen(false);
|
||||
}
|
||||
|
||||
function switchMode(nextMode: AuthMode) {
|
||||
if (nextMode === mode) return;
|
||||
setMode(nextMode);
|
||||
resetRegistrationDetails();
|
||||
setInviteCode("");
|
||||
setRegistrationId(undefined);
|
||||
setSendState("idle");
|
||||
setCountdown(0);
|
||||
setError(undefined);
|
||||
}
|
||||
|
||||
function modifyRegistrationEntry() {
|
||||
resetRegistrationDetails();
|
||||
setRegistrationId(undefined);
|
||||
setSendState("idle");
|
||||
setCountdown(0);
|
||||
setError(undefined);
|
||||
window.requestAnimationFrame(() => document.getElementById(inviteId)?.focus());
|
||||
}
|
||||
|
||||
function closeNotice() {
|
||||
setNoticeOpen(false);
|
||||
window.requestAnimationFrame(() => noticeButton.current?.focus());
|
||||
}
|
||||
|
||||
function handleNoticeKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeNotice();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const focusable = Array.from(
|
||||
noticeDialog.current?.querySelectorAll<HTMLElement>("button, [href], input, [tabindex]:not([tabindex='-1'])") ?? [],
|
||||
).filter((element) => !element.hasAttribute("disabled"));
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(nextMode: AuthMode) {
|
||||
switchMode(nextMode);
|
||||
window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus());
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
if (!emailValid || sendState === "sending" || countdown > 0) return;
|
||||
setSendState("sending");
|
||||
setError(undefined);
|
||||
const endpoint = mode === "login" ? "/api/v1/auth/login/send" : "/api/v1/auth/register/send";
|
||||
const payload = mode === "login" ? { email } : { email, invite_code: inviteCode };
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
body: JSON.stringify(payload),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string };
|
||||
if (!response.ok || !body.registration_id) {
|
||||
setSendState("error");
|
||||
setError(errorMessage(body));
|
||||
return;
|
||||
}
|
||||
setRegistrationId(body.registration_id);
|
||||
setSendState("sent");
|
||||
setCountdown(60);
|
||||
} catch {
|
||||
setSendState("error");
|
||||
setError(messageByKey["auth.service.unavailable"]);
|
||||
}
|
||||
}
|
||||
|
||||
async function login(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/login/complete", {
|
||||
body: JSON.stringify({ registration_id: registrationId, verification_code: code }),
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.json() as ErrorEnvelopeBody;
|
||||
if (!response.ok) {
|
||||
setError(errorMessage(body));
|
||||
return;
|
||||
}
|
||||
window.location.assign("/app");
|
||||
} catch {
|
||||
setError("登录请求未完成,请重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function completeRegistration(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!registrationReady || !registrationId || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/register/complete", {
|
||||
body: JSON.stringify({
|
||||
creator_name: creatorName,
|
||||
privacy_consent_accepted: privacyConsentAccepted,
|
||||
privacy_notice_version: registrationNotice.version,
|
||||
registration_id: registrationId,
|
||||
social_id: socialHandle,
|
||||
verification_code: code,
|
||||
}),
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.json() as ErrorEnvelopeBody;
|
||||
if (!response.ok) {
|
||||
setError(errorMessage(body));
|
||||
return;
|
||||
}
|
||||
window.location.assign("/app");
|
||||
} catch {
|
||||
setError("注册请求未完成,请重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="auth-page">
|
||||
<section className="auth-art" aria-label="Dada 创作艺术带">
|
||||
<div className="auth-wordmark">DADA</div>
|
||||
<div className="auth-art-copy">
|
||||
<span>LOCAL CREATIVE SYSTEM / WINDOWS P0-A</span>
|
||||
<strong>在本机展开你的创作</strong>
|
||||
</div>
|
||||
<div className="auth-art-block" aria-hidden="true" />
|
||||
<div className="auth-art-line" aria-hidden="true" />
|
||||
</section>
|
||||
|
||||
<section className="auth-content">
|
||||
<a className="auth-admin-link" href="/admin/login">管理员登录</a>
|
||||
<div className="auth-panel">
|
||||
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
||||
<button
|
||||
aria-selected={mode === "login"}
|
||||
className="auth-tab"
|
||||
onClick={() => switchMode("login")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
switchTab("register");
|
||||
}
|
||||
}}
|
||||
ref={loginTab}
|
||||
role="tab"
|
||||
tabIndex={mode === "login" ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
aria-selected={mode === "register"}
|
||||
className="auth-tab"
|
||||
onClick={() => switchMode("register")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
switchTab("login");
|
||||
}
|
||||
}}
|
||||
ref={registerTab}
|
||||
role="tab"
|
||||
tabIndex={mode === "register" ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "login" ? (
|
||||
<form className="auth-form" onSubmit={login}>
|
||||
<h1>邮箱验证码登录</h1>
|
||||
<label htmlFor={emailId}>邮箱</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
id={emailId}
|
||||
inputMode="email"
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
<label htmlFor={codeId}>验证码</label>
|
||||
<div className="auth-code-row">
|
||||
<input
|
||||
autoComplete="one-time-code"
|
||||
id={codeId}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
|
||||
placeholder="6 位验证码"
|
||||
value={code}
|
||||
/>
|
||||
<button
|
||||
className="auth-secondary"
|
||||
disabled={!emailValid || sendState === "sending" || countdown > 0}
|
||||
onClick={sendCode}
|
||||
type="button"
|
||||
>
|
||||
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||
</button>
|
||||
</div>
|
||||
{sendState === "sent" ? (
|
||||
<p className="auth-status" role="status">验证码已发送至 {maskedEmail(email)}</p>
|
||||
) : null}
|
||||
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||
<button
|
||||
className="auth-primary"
|
||||
disabled={!registrationId || code.length !== 6 || submitting}
|
||||
type="submit"
|
||||
>
|
||||
{submitting ? "登录中" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="auth-form auth-registration-form" onSubmit={completeRegistration}>
|
||||
<h1>{registrationId ? "完善注册资料" : "邀请码注册"}</h1>
|
||||
{!registrationId ? (
|
||||
<>
|
||||
<label htmlFor={inviteId}>邀请码</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
id={inviteId}
|
||||
onChange={(event) => setInviteCode(event.target.value)}
|
||||
value={inviteCode}
|
||||
/>
|
||||
<label htmlFor={emailId}>邮箱</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
id={emailId}
|
||||
inputMode="email"
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
<button
|
||||
className="auth-primary"
|
||||
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
|
||||
onClick={sendCode}
|
||||
type="button"
|
||||
>
|
||||
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="auth-registration-status">验证码已发送</div>
|
||||
<div className="auth-verified-grid">
|
||||
<div>
|
||||
<strong>邀请码 · 已验证</strong>
|
||||
<span>{maskedInvite(inviteCode)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>邮箱 · 已验证</strong>
|
||||
<span>{maskedRegistrationEmail(email)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="auth-text-action" onClick={modifyRegistrationEntry} type="button">
|
||||
修改邀请码和邮箱
|
||||
</button>
|
||||
<label htmlFor={codeId}>验证码</label>
|
||||
<input
|
||||
autoComplete="one-time-code"
|
||||
id={codeId}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
|
||||
placeholder="输入 6 位验证码"
|
||||
value={code}
|
||||
/>
|
||||
<label htmlFor={creatorNameId}>创作署名</label>
|
||||
<input
|
||||
autoComplete="nickname"
|
||||
id={creatorNameId}
|
||||
maxLength={80}
|
||||
onChange={(event) => setCreatorName(event.target.value)}
|
||||
placeholder="成品中显示的名称"
|
||||
value={creatorName}
|
||||
/>
|
||||
<label htmlFor={socialId}>社交 ID</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
id={socialId}
|
||||
maxLength={80}
|
||||
onChange={(event) => setSocialHandle(event.target.value)}
|
||||
placeholder="成品中显示的账号文本"
|
||||
value={socialHandle}
|
||||
/>
|
||||
<div className="auth-consent">
|
||||
<div className="auth-consent-title">
|
||||
<strong>《内测使用与隐私告知》</strong>
|
||||
<button
|
||||
className="auth-text-action"
|
||||
onClick={() => setNoticeOpen(true)}
|
||||
ref={noticeButton}
|
||||
type="button"
|
||||
>
|
||||
查看全文
|
||||
</button>
|
||||
</div>
|
||||
<label className="auth-checkbox">
|
||||
<input
|
||||
checked={privacyConsentAccepted}
|
||||
onChange={(event) => setPrivacyConsentAccepted(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
我已阅读并同意《内测使用与隐私告知》
|
||||
</label>
|
||||
{!privacyConsentAccepted ? <p>完成注册前必须阅读并勾选同意。</p> : null}
|
||||
</div>
|
||||
<button className="auth-primary" disabled={!registrationReady || submitting} type="submit">
|
||||
{submitting ? "注册中" : "注册并进入 Dada"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="auth-local-notice">
|
||||
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
|
||||
</footer>
|
||||
</main>
|
||||
{noticeOpen ? (
|
||||
<div className="auth-dialog-backdrop">
|
||||
<section
|
||||
aria-labelledby="registration-notice-title"
|
||||
aria-modal="true"
|
||||
className="auth-dialog"
|
||||
onKeyDown={handleNoticeKeyDown}
|
||||
ref={noticeDialog}
|
||||
role="dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p>版本 {registrationNotice.version} · 生效日期 {registrationNotice.effectiveAt}</p>
|
||||
<h2 id="registration-notice-title">{registrationNotice.title}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="auth-dialog-content">
|
||||
{registrationNotice.sections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.body}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<footer>
|
||||
<button className="auth-primary" onClick={closeNotice} ref={noticeCloseButton} type="button">
|
||||
我已阅读
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { GenerationErrorCategory } from "./generation-error-registry.js";
|
||||
|
||||
export interface GenerationAdapterRequest {
|
||||
configSnapshot: Readonly<Record<string, unknown>>;
|
||||
generationId: string;
|
||||
modelId: string;
|
||||
prompt: string;
|
||||
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||
referenceAssetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface NormalizedGenerationOutput {
|
||||
bytes: Buffer;
|
||||
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||
pixelHeight: number;
|
||||
pixelWidth: number;
|
||||
}
|
||||
|
||||
export type GenerationAdapterResult =
|
||||
| { outputs: readonly NormalizedGenerationOutput[]; status: "completed" }
|
||||
| { status: "pending"; upstreamJobReference: string }
|
||||
| {
|
||||
balanceSignal?: { gatewayAccountRef: string; impactScope: "model" | "account" | "unknown" };
|
||||
category: GenerationErrorCategory;
|
||||
sourceCategory: string;
|
||||
status: "failed";
|
||||
};
|
||||
|
||||
export interface GenerationAdapter {
|
||||
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
||||
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
|
||||
}
|
||||
|
||||
type MockResult = GenerationAdapterResult & { unsafeRaw?: string };
|
||||
|
||||
export class MockGenerationAdapter implements GenerationAdapter {
|
||||
readonly calls: Array<{ generationId: string; modelId: string }> = [];
|
||||
private readonly result: MockResult;
|
||||
|
||||
constructor(result: MockResult) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
|
||||
this.calls.push({ generationId: request.generationId, modelId: request.modelId });
|
||||
if (this.result.status === "pending") return { status: "pending", upstreamJobReference: this.result.upstreamJobReference };
|
||||
if (this.result.status === "completed") {
|
||||
return { outputs: this.result.outputs.map((output) => ({ ...output, bytes: Buffer.from(output.bytes) })), status: "completed" };
|
||||
}
|
||||
return {
|
||||
...(this.result.balanceSignal ? { balanceSignal: { ...this.result.balanceSignal } } : {}),
|
||||
category: this.result.category,
|
||||
sourceCategory: this.result.sourceCategory,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import {
|
||||
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||
type ModelAdapter, supportedAdapterRatios,
|
||||
} from "./model-adapter.js";
|
||||
|
||||
const expectedContract = {
|
||||
endpoint: "https://mock.invalid/gemini/flash",
|
||||
fixture_id: "gemini-flash-mock-v1",
|
||||
mode: "sync",
|
||||
protocol_version: "gemini-flash-v1",
|
||||
response_shape: "candidates.inline_data",
|
||||
supports_reference_images: true,
|
||||
supported_ratios: [...supportedAdapterRatios],
|
||||
};
|
||||
|
||||
function defaultTransport(): AdapterTransport {
|
||||
return {
|
||||
async start(request) {
|
||||
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||
return { candidates: [{ inline_data: { data: mockPngBytes("flash").toString("base64"), mime_type: "image/png" }, ...dimensions }] };
|
||||
},
|
||||
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||
};
|
||||
}
|
||||
|
||||
export class GeminiFlashAdapter implements ModelAdapter {
|
||||
readonly contractVersion = "gemini-flash-v1";
|
||||
readonly fixtureId = "gemini-flash-mock-v1";
|
||||
readonly modelId = "gemini-3.1-flash-image-preview";
|
||||
private readonly transport: AdapterTransport;
|
||||
|
||||
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||
try {
|
||||
validateAdapterRequest(request, this.modelId);
|
||||
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||
if (response && typeof response === "object" && "status" in response && response.status === "failed") {
|
||||
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
return { outputs: [this.normalizeOutput(response)], status: "completed" };
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||
void upstreamJobReference;
|
||||
return { category: "gateway_contract_invalid", sourceCategory: "unexpected_poll", status: "failed" };
|
||||
}
|
||||
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||
if (!response || typeof response !== "object" || !("candidates" in response) || !Array.isArray(response.candidates) || response.candidates.length !== 1) {
|
||||
throw new AdapterContractError("response_single_image_required");
|
||||
}
|
||||
const candidate = response.candidates[0];
|
||||
if (!candidate || typeof candidate !== "object" || !("inline_data" in candidate) || !candidate.inline_data || typeof candidate.inline_data !== "object") {
|
||||
throw new AdapterContractError("response_shape_invalid");
|
||||
}
|
||||
const data = "data" in candidate.inline_data && typeof candidate.inline_data.data === "string" ? Buffer.from(candidate.inline_data.data, "base64") : undefined;
|
||||
const mimeType = "mime_type" in candidate.inline_data && candidate.inline_data.mime_type === "image/png" ? "image/png" : undefined;
|
||||
const pixelHeight = "pixelHeight" in candidate && typeof candidate.pixelHeight === "number" ? candidate.pixelHeight : undefined;
|
||||
const pixelWidth = "pixelWidth" in candidate && typeof candidate.pixelWidth === "number" ? candidate.pixelWidth : undefined;
|
||||
if (!data || !data.length || !mimeType || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||
return { bytes: data, mimeType, pixelHeight, pixelWidth };
|
||||
}
|
||||
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>) {
|
||||
return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion);
|
||||
}
|
||||
}
|
||||
|
||||
export { expectedContract as geminiFlashMockContract };
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import {
|
||||
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||
type ModelAdapter, supportedAdapterRatios,
|
||||
} from "./model-adapter.js";
|
||||
|
||||
const expectedContract = {
|
||||
endpoint: "https://mock.invalid/gemini/pro",
|
||||
fixture_id: "gemini-pro-mock-v1",
|
||||
mode: "async",
|
||||
protocol_version: "gemini-pro-v1",
|
||||
response_shape: "operation.poll.candidates",
|
||||
supports_reference_images: true,
|
||||
supported_ratios: [...supportedAdapterRatios],
|
||||
};
|
||||
|
||||
function defaultTransport(): AdapterTransport {
|
||||
return {
|
||||
async start(request) {
|
||||
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||
return { operation: { done: true, response: { candidates: [{ inline_data: { data: mockPngBytes("pro").toString("base64"), mime_type: "image/png" }, ...dimensions }] } } };
|
||||
},
|
||||
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||
};
|
||||
}
|
||||
|
||||
export class GeminiProAdapter implements ModelAdapter {
|
||||
readonly contractVersion = "gemini-pro-v1";
|
||||
readonly fixtureId = "gemini-pro-mock-v1";
|
||||
readonly modelId = "gemini-3-pro-image-preview";
|
||||
private readonly transport: AdapterTransport;
|
||||
|
||||
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||
try {
|
||||
validateAdapterRequest(request, this.modelId);
|
||||
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||
return this.interpret(response, request.configSnapshot.error_mapping_profile as Record<string, string> ?? {});
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||
try {
|
||||
const response = await this.transport.poll({ operation: "poll", modelId: this.modelId, upstreamJobReference });
|
||||
return this.interpret(response, {});
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
private interpret(response: unknown, mappingProfile: Readonly<Record<string, string>>): AdapterStartResult {
|
||||
if (!response || typeof response !== "object" || !("operation" in response) || !response.operation || typeof response.operation !== "object") {
|
||||
return { category: "gateway_contract_invalid", sourceCategory: "response_shape_invalid", status: "failed" };
|
||||
}
|
||||
if ("status" in response && response.status === "failed") {
|
||||
const classified = this.classifyError("error" in response ? response.error : undefined, mappingProfile);
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
const operation = response.operation;
|
||||
if ("done" in operation && operation.done === false) {
|
||||
const reference = "name" in operation && typeof operation.name === "string" ? operation.name : undefined;
|
||||
return reference ? { status: "pending", upstreamJobReference: reference } : { category: "gateway_contract_invalid", sourceCategory: "upstream_reference_missing", status: "failed" };
|
||||
}
|
||||
try {
|
||||
return { outputs: [this.normalizeOutput("response" in operation ? operation.response : undefined)], status: "completed" };
|
||||
} catch (error) {
|
||||
return { category: "gateway_contract_invalid", sourceCategory: error instanceof AdapterContractError ? error.sourceCategory : "response_shape_invalid", status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||
if (!response || typeof response !== "object" || !("candidates" in response) || !Array.isArray(response.candidates) || response.candidates.length !== 1) throw new AdapterContractError("response_single_image_required");
|
||||
const candidate = response.candidates[0];
|
||||
if (!candidate || typeof candidate !== "object" || !("inline_data" in candidate) || !candidate.inline_data || typeof candidate.inline_data !== "object") throw new AdapterContractError("response_shape_invalid");
|
||||
const data = "data" in candidate.inline_data && typeof candidate.inline_data.data === "string" ? Buffer.from(candidate.inline_data.data, "base64") : undefined;
|
||||
const mimeType = "mime_type" in candidate.inline_data && candidate.inline_data.mime_type === "image/png" ? "image/png" : undefined;
|
||||
const pixelHeight = "pixelHeight" in candidate && typeof candidate.pixelHeight === "number" ? candidate.pixelHeight : undefined;
|
||||
const pixelWidth = "pixelWidth" in candidate && typeof candidate.pixelWidth === "number" ? candidate.pixelWidth : undefined;
|
||||
if (!data || !data.length || !mimeType || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||
return { bytes: data, mimeType, pixelHeight, pixelWidth };
|
||||
}
|
||||
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>) { return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion); }
|
||||
}
|
||||
|
||||
export { expectedContract as geminiProMockContract };
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import {
|
||||
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||
type ModelAdapter, supportedAdapterRatios,
|
||||
} from "./model-adapter.js";
|
||||
|
||||
const expectedContract = {
|
||||
endpoint: "https://mock.invalid/openai/images",
|
||||
fixture_id: "gpt-image-mock-v1",
|
||||
mode: "sync",
|
||||
protocol_version: "gpt-image-v1",
|
||||
response_shape: "data.b64_json",
|
||||
supports_reference_images: true,
|
||||
supported_ratios: [...supportedAdapterRatios],
|
||||
};
|
||||
|
||||
function defaultTransport(): AdapterTransport {
|
||||
return {
|
||||
async start(request) {
|
||||
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||
return { data: [{ b64_json: mockPngBytes("gpt-image").toString("base64"), ...dimensions }] };
|
||||
},
|
||||
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||
};
|
||||
}
|
||||
|
||||
export class GptImageAdapter implements ModelAdapter {
|
||||
readonly contractVersion = "gpt-image-v1";
|
||||
readonly fixtureId = "gpt-image-mock-v1";
|
||||
readonly modelId = "gpt-image-2";
|
||||
private readonly transport: AdapterTransport;
|
||||
|
||||
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||
|
||||
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||
try {
|
||||
validateAdapterRequest(request, this.modelId);
|
||||
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||
if (response && typeof response === "object" && "status" in response && response.status === "failed") {
|
||||
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
return { outputs: [this.normalizeOutput(response)], status: "completed" };
|
||||
} catch (error) {
|
||||
const classified = error instanceof AdapterContractError
|
||||
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||
: this.classifyError(error, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||
return { ...classified, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||
void upstreamJobReference;
|
||||
return { category: "gateway_contract_invalid", sourceCategory: "unexpected_poll", status: "failed" };
|
||||
}
|
||||
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data) || response.data.length !== 1) throw new AdapterContractError("response_single_image_required");
|
||||
const image = response.data[0];
|
||||
if (!image || typeof image !== "object") throw new AdapterContractError("response_shape_invalid");
|
||||
const data = "b64_json" in image && typeof image.b64_json === "string" ? Buffer.from(image.b64_json, "base64") : undefined;
|
||||
const pixelHeight = "pixelHeight" in image && typeof image.pixelHeight === "number" ? image.pixelHeight : undefined;
|
||||
const pixelWidth = "pixelWidth" in image && typeof image.pixelWidth === "number" ? image.pixelWidth : undefined;
|
||||
if (!data || !data.length || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||
return { bytes: data, mimeType: "image/png", pixelHeight, pixelWidth };
|
||||
}
|
||||
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>) { return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion); }
|
||||
}
|
||||
|
||||
export { expectedContract as gptImageMockContract };
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface CreditSettlementTarget {
|
||||
finalizeGeneration(input: {
|
||||
generationId: string;
|
||||
operationKey: string;
|
||||
outcome: "succeeded" | "failed" | "rejected";
|
||||
}): unknown;
|
||||
}
|
||||
|
||||
export function settleGenerationCredits(
|
||||
target: CreditSettlementTarget,
|
||||
input: {
|
||||
generationId: string;
|
||||
operationKey: string;
|
||||
outcome: "succeeded" | "failed" | "rejected";
|
||||
},
|
||||
) {
|
||||
return target.finalizeGeneration(input);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||
|
||||
const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||
const recoveryCheckLifetimeMilliseconds = 5 * 60 * 1_000;
|
||||
const accountRefPattern = /^[a-z][a-z0-9_-]{2,119}$/;
|
||||
const sourceCategoryPattern = /^[a-z][a-z0-9_]{0,79}$/;
|
||||
const runtimeReasons = [
|
||||
"available", "configured_disabled", "contract_unverified", "contract_blocked",
|
||||
"gateway_balance_insufficient", "gateway_paused", "worker_degraded",
|
||||
] as const;
|
||||
type RuntimeReason = typeof runtimeReasons[number];
|
||||
|
||||
interface RuntimeRow {
|
||||
available_for_new_jobs: number;
|
||||
checked_at: number;
|
||||
model_id: string;
|
||||
reason: RuntimeReason;
|
||||
runtime_availability_version: number;
|
||||
}
|
||||
|
||||
interface MappingRow {
|
||||
balance_blocked: number;
|
||||
gateway_account_ref: string;
|
||||
model_id: string;
|
||||
previous_runtime_reason: RuntimeReason | null;
|
||||
}
|
||||
|
||||
interface BalanceRow {
|
||||
affected_model_ids_json: string;
|
||||
balance_status: "available" | "insufficient" | "unknown";
|
||||
detected_at: number;
|
||||
gateway_account_ref: string;
|
||||
impact_scope: "model" | "account" | "unknown";
|
||||
last_confirmed_at: number | null;
|
||||
recovery_status: "not_required" | "awaiting_confirmation" | "confirmed";
|
||||
runtime_unavailable_model_ids_json: string;
|
||||
source_category: string;
|
||||
}
|
||||
|
||||
export interface GatewayBalanceView {
|
||||
affectedModelIds: string[];
|
||||
balanceStatus: "available" | "insufficient" | "unknown";
|
||||
detectedAt: string;
|
||||
gatewayAccountRef: string;
|
||||
impactScope: "model" | "account" | "unknown";
|
||||
lastConfirmedAt: string | null;
|
||||
recoveryStatus: "not_required" | "awaiting_confirmation" | "confirmed";
|
||||
runtimeUnavailableModelIds: string[];
|
||||
sourceCategory: string;
|
||||
}
|
||||
|
||||
export interface GatewayRecoveryCheck {
|
||||
check_id: string;
|
||||
expires_at: string;
|
||||
gateway_account_ref: string;
|
||||
status: "passed";
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export class GatewayBalanceRuntime {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
private readonly ownsDatabase: boolean;
|
||||
|
||||
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
|
||||
if (!input.database && !input.databasePath) throw new Error("gateway_balance_database_required");
|
||||
this.database = input.database ?? new Database(input.databasePath!);
|
||||
this.ownsDatabase = !input.database;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
if (this.ownsDatabase) configureWorkerDatabase(this.database);
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.ownsDatabase) this.database.close();
|
||||
}
|
||||
|
||||
seedModels(models: Array<{ gatewayAccountRef: string; modelId: string }>) {
|
||||
const now = this.clock();
|
||||
this.immediate(() => {
|
||||
for (const model of models) {
|
||||
if (!accountRefPattern.test(model.gatewayAccountRef) || !model.modelId) throw new Error("gateway_balance_model_invalid");
|
||||
this.database.prepare(`
|
||||
INSERT INTO model_runtime_availability (
|
||||
model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version
|
||||
) VALUES (?, 1, 'available', ?, 1)
|
||||
ON CONFLICT(model_id) DO NOTHING
|
||||
`).run(model.modelId, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO gateway_balance_affected_models (gateway_account_ref, model_id, balance_blocked, previous_runtime_reason)
|
||||
VALUES (?, ?, 0, NULL)
|
||||
ON CONFLICT(gateway_account_ref, model_id) DO NOTHING
|
||||
`).run(model.gatewayAccountRef, model.modelId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recordInsufficient(input: {
|
||||
eventId: string;
|
||||
gatewayAccountRef: string;
|
||||
impactScope: "model" | "account" | "unknown";
|
||||
modelId: string;
|
||||
sourceCategory: string;
|
||||
}) {
|
||||
if (!input.eventId || !accountRefPattern.test(input.gatewayAccountRef) || !input.modelId
|
||||
|| !sourceCategoryPattern.test(input.sourceCategory)) throw new Error("gateway_balance_signal_invalid");
|
||||
return this.immediate(() => {
|
||||
const receipt = this.database.prepare("SELECT response_json FROM gateway_balance_event_receipts WHERE event_id = ?")
|
||||
.get(input.eventId) as { response_json: string } | undefined;
|
||||
if (receipt) return JSON.parse(receipt.response_json) as GatewayBalanceView;
|
||||
|
||||
this.ensureModelMapping(input.gatewayAccountRef, input.modelId);
|
||||
const accountModels = this.database.prepare("SELECT model_id FROM gateway_balance_affected_models WHERE gateway_account_ref = ? ORDER BY model_id")
|
||||
.all(input.gatewayAccountRef) as Array<{ model_id: string }>;
|
||||
const newlyAffected = input.impactScope === "model" ? [input.modelId] : accountModels.map((row) => row.model_id);
|
||||
const existing = this.database.prepare("SELECT runtime_unavailable_model_ids_json FROM gateway_balance_states WHERE gateway_account_ref = ?")
|
||||
.get(input.gatewayAccountRef) as { runtime_unavailable_model_ids_json: string } | undefined;
|
||||
const unavailable = [...new Set([...(existing ? JSON.parse(existing.runtime_unavailable_model_ids_json) as string[] : []), ...newlyAffected])].toSorted();
|
||||
const now = this.clock();
|
||||
for (const modelId of unavailable) {
|
||||
const mapping = this.database.prepare("SELECT * FROM gateway_balance_affected_models WHERE gateway_account_ref = ? AND model_id = ?")
|
||||
.get(input.gatewayAccountRef, modelId) as MappingRow | undefined;
|
||||
const runtime = this.database.prepare("SELECT * FROM model_runtime_availability WHERE model_id = ?").get(modelId) as RuntimeRow | undefined;
|
||||
if (!mapping || !runtime) continue;
|
||||
if (mapping.balance_blocked === 0) {
|
||||
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 1, previous_runtime_reason = ? WHERE gateway_account_ref = ? AND model_id = ?")
|
||||
.run(runtime.reason, input.gatewayAccountRef, modelId);
|
||||
} else {
|
||||
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 1 WHERE gateway_account_ref = ? AND model_id = ?")
|
||||
.run(input.gatewayAccountRef, modelId);
|
||||
}
|
||||
const reason = runtime.reason === "configured_disabled" || runtime.reason === "contract_unverified" || runtime.reason === "contract_blocked"
|
||||
? runtime.reason : "gateway_balance_insufficient";
|
||||
this.updateRuntime(modelId, false, reason, now);
|
||||
}
|
||||
this.database.prepare(`
|
||||
INSERT INTO gateway_balance_states (
|
||||
gateway_account_ref, balance_status, impact_scope, affected_model_ids_json,
|
||||
runtime_unavailable_model_ids_json, detected_at, last_confirmed_at, recovery_status, source_category
|
||||
) VALUES (?, 'insufficient', ?, ?, ?, ?, NULL, 'awaiting_confirmation', ?)
|
||||
ON CONFLICT(gateway_account_ref) DO UPDATE SET
|
||||
balance_status = 'insufficient', impact_scope = excluded.impact_scope,
|
||||
affected_model_ids_json = excluded.affected_model_ids_json,
|
||||
runtime_unavailable_model_ids_json = excluded.runtime_unavailable_model_ids_json,
|
||||
detected_at = excluded.detected_at, last_confirmed_at = NULL,
|
||||
recovery_status = 'awaiting_confirmation', source_category = excluded.source_category
|
||||
`).run(input.gatewayAccountRef, input.impactScope, JSON.stringify(newlyAffected.toSorted()), JSON.stringify(unavailable), now, input.sourceCategory);
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'system', 'generation_worker', 'gateway_balance_detected', 'gateway_balance_state', ?, 'succeeded', NULL, ?, ?, ?)
|
||||
`).run(randomUUID(), input.gatewayAccountRef, JSON.stringify({ impact_scope: input.impactScope, runtime_unavailable_model_ids: unavailable }), now, now + auditRetentionMilliseconds);
|
||||
const result = this.readState(input.gatewayAccountRef)!;
|
||||
this.database.prepare("INSERT INTO gateway_balance_event_receipts (event_id, gateway_account_ref, response_json, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(input.eventId, input.gatewayAccountRef, JSON.stringify(result), now);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
readState(gatewayAccountRef: string) {
|
||||
const row = this.database.prepare("SELECT * FROM gateway_balance_states WHERE gateway_account_ref = ?").get(gatewayAccountRef) as BalanceRow | undefined;
|
||||
return row ? this.view(row) : undefined;
|
||||
}
|
||||
|
||||
listRuntime() {
|
||||
const rows = this.database.prepare(`
|
||||
SELECT r.*, COALESCE(m.gateway_account_ref, 'unknown') AS gateway_account_ref,
|
||||
COALESCE(m.balance_blocked, 0) AS balance_blocked
|
||||
FROM model_runtime_availability r
|
||||
LEFT JOIN gateway_balance_affected_models m ON m.model_id = r.model_id
|
||||
ORDER BY r.model_id
|
||||
`).all() as Array<RuntimeRow & { balance_blocked: number; gateway_account_ref: string }>;
|
||||
return rows.map((row) => ({
|
||||
availableForNewJobs: row.available_for_new_jobs === 1,
|
||||
balanceBlocked: row.balance_blocked === 1,
|
||||
gatewayAccountRef: row.gateway_account_ref,
|
||||
modelId: row.model_id,
|
||||
runtimeReason: row.reason,
|
||||
runtimeVersion: row.runtime_availability_version,
|
||||
updatedAt: new Date(row.checked_at).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
runRecoveryCheck(gatewayAccountRef: string): GatewayRecoveryCheck {
|
||||
const state = this.readState(gatewayAccountRef);
|
||||
if (!state || state.recoveryStatus !== "awaiting_confirmation") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||
const now = this.clock();
|
||||
const check = {
|
||||
check_id: randomUUID(), expires_at: new Date(now + recoveryCheckLifetimeMilliseconds).toISOString(),
|
||||
gateway_account_ref: gatewayAccountRef, status: "passed" as const, checked_at: new Date(now).toISOString(),
|
||||
};
|
||||
this.immediate(() => {
|
||||
this.database.prepare(`
|
||||
INSERT INTO service_recovery_checks (check_id, service_name, target_ref, status, checked_at, expires_at, details_json)
|
||||
VALUES (?, 'gateway_balance', ?, 'passed', ?, ?, ?)
|
||||
`).run(check.check_id, gatewayAccountRef, now, now + recoveryCheckLifetimeMilliseconds, JSON.stringify({ non_sensitive: true }));
|
||||
});
|
||||
return check;
|
||||
}
|
||||
|
||||
confirmRecovery(input: { actorId: string; gatewayAccountRef: string; recoveryCheckId?: string }) {
|
||||
return this.immediate(() => {
|
||||
const state = this.database.prepare("SELECT * FROM gateway_balance_states WHERE gateway_account_ref = ?").get(input.gatewayAccountRef) as BalanceRow | undefined;
|
||||
if (!state || state.recovery_status !== "awaiting_confirmation") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||
const check = input.recoveryCheckId
|
||||
? this.database.prepare("SELECT * FROM service_recovery_checks WHERE check_id = ? AND target_ref = ? AND service_name = 'gateway_balance'").get(input.recoveryCheckId, input.gatewayAccountRef) as { expires_at: number; status: string } | undefined
|
||||
: undefined;
|
||||
if (!check || check.status !== "passed" || check.expires_at < this.clock()) throw new Error("gateway_balance_recovery_unconfirmed");
|
||||
const now = this.clock();
|
||||
const rows = this.database.prepare("SELECT * FROM gateway_balance_affected_models WHERE gateway_account_ref = ? AND balance_blocked = 1")
|
||||
.all(input.gatewayAccountRef) as MappingRow[];
|
||||
for (const row of rows) {
|
||||
const configuredReason = this.configuredRuntimeReason(row.model_id);
|
||||
const reason = configuredReason === "available" ? (row.previous_runtime_reason ?? "available") : configuredReason;
|
||||
this.updateRuntime(row.model_id, reason === "available", reason, now);
|
||||
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 0, previous_runtime_reason = NULL WHERE gateway_account_ref = ? AND model_id = ?")
|
||||
.run(input.gatewayAccountRef, row.model_id);
|
||||
}
|
||||
this.database.prepare(`
|
||||
UPDATE gateway_balance_states SET balance_status = 'available', recovery_status = 'confirmed', last_confirmed_at = ?
|
||||
WHERE gateway_account_ref = ?
|
||||
`).run(now, input.gatewayAccountRef);
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'super_admin', ?, 'gateway_balance_recovery', 'gateway_balance_state', ?, 'succeeded', ?, ?, ?, ?)
|
||||
`).run(randomUUID(), input.actorId, input.gatewayAccountRef,
|
||||
JSON.stringify({ recovery_status: "awaiting_confirmation" }),
|
||||
JSON.stringify({ recovery_status: "confirmed", restored_model_count: rows.length }), now, now + auditRetentionMilliseconds);
|
||||
return this.readState(input.gatewayAccountRef)!;
|
||||
});
|
||||
}
|
||||
|
||||
restoreWithoutConfirmedRecovery(gatewayAccountRef: string, _actorId: string) {
|
||||
const state = this.readState(gatewayAccountRef);
|
||||
if (!state || state.recoveryStatus !== "confirmed") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||
throw new Error("gateway_balance_recovery_owned_by_wp3");
|
||||
}
|
||||
|
||||
private ensureModelMapping(gatewayAccountRef: string, modelId: string) {
|
||||
this.database.prepare(`
|
||||
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||
VALUES (?, 1, 'available', ?, 1)
|
||||
ON CONFLICT(model_id) DO NOTHING
|
||||
`).run(modelId, this.clock());
|
||||
this.database.prepare(`
|
||||
INSERT INTO gateway_balance_affected_models (gateway_account_ref, model_id, balance_blocked, previous_runtime_reason)
|
||||
VALUES (?, ?, 0, NULL)
|
||||
ON CONFLICT(gateway_account_ref, model_id) DO NOTHING
|
||||
`).run(gatewayAccountRef, modelId);
|
||||
}
|
||||
|
||||
private updateRuntime(modelId: string, available: boolean, reason: RuntimeReason, now: number) {
|
||||
this.database.prepare(`
|
||||
UPDATE model_runtime_availability
|
||||
SET available_for_new_jobs = ?, reason = ?, checked_at = ?, runtime_availability_version = runtime_availability_version + 1
|
||||
WHERE model_id = ?
|
||||
`).run(available ? 1 : 0, reason, now, modelId);
|
||||
}
|
||||
|
||||
private configuredRuntimeReason(modelId: string): RuntimeReason {
|
||||
const row = this.database.prepare(`
|
||||
SELECT m.enabled, v.contract_validation_status
|
||||
FROM model_config_current c
|
||||
JOIN model_config_set_members m ON m.config_set_id = c.config_set_id AND c.singleton = 1
|
||||
JOIN model_config_versions v ON v.model_id = m.model_id AND v.config_version = m.config_version
|
||||
WHERE m.model_id = ?
|
||||
`).get(modelId) as { contract_validation_status: "blocked" | "unverified" | "verified"; enabled: number } | undefined;
|
||||
if (!row) return "available";
|
||||
if (row.enabled !== 1) return "configured_disabled";
|
||||
if (row.contract_validation_status === "blocked") return "contract_blocked";
|
||||
if (row.contract_validation_status !== "verified") return "contract_unverified";
|
||||
return "available";
|
||||
}
|
||||
|
||||
private view(row: BalanceRow): GatewayBalanceView {
|
||||
return {
|
||||
affectedModelIds: JSON.parse(row.affected_model_ids_json) as string[],
|
||||
balanceStatus: row.balance_status,
|
||||
detectedAt: new Date(row.detected_at).toISOString(),
|
||||
gatewayAccountRef: row.gateway_account_ref,
|
||||
impactScope: row.impact_scope,
|
||||
lastConfirmedAt: row.last_confirmed_at === null ? null : new Date(row.last_confirmed_at).toISOString(),
|
||||
recoveryStatus: row.recovery_status,
|
||||
runtimeUnavailableModelIds: JSON.parse(row.runtime_unavailable_model_ids_json) as string[],
|
||||
sourceCategory: row.source_category,
|
||||
};
|
||||
}
|
||||
|
||||
private immediate<T>(action: () => T) {
|
||||
if (this.database.inTransaction) return action();
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
this.database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
const existingRuntimeColumns = new Set((this.database.prepare("PRAGMA table_info(model_runtime_availability)").all() as Array<{ name: string }>).map((column) => column.name));
|
||||
if (existingRuntimeColumns.has("gateway_account_ref")) {
|
||||
this.database.exec(`
|
||||
CREATE TABLE model_runtime_availability_v2 (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||
checked_at INTEGER NOT NULL,
|
||||
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||
);
|
||||
INSERT INTO model_runtime_availability_v2 (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||
SELECT model_id, available_for_new_jobs, runtime_reason, updated_at, runtime_version FROM model_runtime_availability;
|
||||
DROP TABLE model_runtime_availability;
|
||||
ALTER TABLE model_runtime_availability_v2 RENAME TO model_runtime_availability;
|
||||
`);
|
||||
} else {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS model_runtime_availability (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||
checked_at INTEGER NOT NULL,
|
||||
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||
);
|
||||
`);
|
||||
}
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gateway_balance_affected_models (
|
||||
gateway_account_ref TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
balance_blocked INTEGER NOT NULL CHECK (balance_blocked IN (0, 1)),
|
||||
previous_runtime_reason TEXT CHECK (previous_runtime_reason IS NULL OR previous_runtime_reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||
PRIMARY KEY (gateway_account_ref, model_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gateway_balance_states (
|
||||
gateway_account_ref TEXT PRIMARY KEY,
|
||||
balance_status TEXT NOT NULL CHECK (balance_status IN ('available', 'insufficient', 'unknown')),
|
||||
impact_scope TEXT NOT NULL CHECK (impact_scope IN ('model', 'account', 'unknown')),
|
||||
affected_model_ids_json TEXT NOT NULL CHECK (json_valid(affected_model_ids_json)),
|
||||
runtime_unavailable_model_ids_json TEXT NOT NULL CHECK (json_valid(runtime_unavailable_model_ids_json)),
|
||||
detected_at INTEGER NOT NULL,
|
||||
last_confirmed_at INTEGER,
|
||||
recovery_status TEXT NOT NULL CHECK (recovery_status IN ('not_required', 'awaiting_confirmation', 'confirmed')),
|
||||
source_category TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gateway_balance_event_receipts (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
gateway_account_ref TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS service_recovery_checks (
|
||||
check_id TEXT PRIMARY KEY,
|
||||
service_name TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('passed', 'failed')),
|
||||
checked_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
details_json TEXT NOT NULL CHECK (json_valid(details_json))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||
log_id TEXT PRIMARY KEY,
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||
actor_ref TEXT NOT NULL,
|
||||
operation_type TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL,
|
||||
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||
before_summary TEXT,
|
||||
after_summary TEXT,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const generationErrorCategories = [
|
||||
"upstream_timeout",
|
||||
"upstream_failed",
|
||||
"safety_rejected",
|
||||
"model_disabled",
|
||||
"gateway_balance_insufficient",
|
||||
"gateway_contract_invalid",
|
||||
"reference_invalid",
|
||||
"unknown_retryable",
|
||||
"unknown_non_retryable",
|
||||
] as const;
|
||||
|
||||
export type GenerationErrorCategory = typeof generationErrorCategories[number];
|
||||
export type GenerationUserAction = "retry_same_input" | "wait_and_retry" | "edit_input" | "choose_model_or_contact_admin" | "contact_admin";
|
||||
|
||||
export interface GenerationErrorDefinition {
|
||||
creditBehavior: "no_reserve" | "release_if_reserved";
|
||||
messageKey: string;
|
||||
retryPolicy: "immediate" | "after_wait" | "after_edit" | "after_model_change" | "none";
|
||||
taskOutcome: "not_created" | "failed" | "rejected" | "failed_or_not_created";
|
||||
userAction: GenerationUserAction;
|
||||
}
|
||||
|
||||
export const generationErrorRegistry: Readonly<Record<GenerationErrorCategory, Readonly<GenerationErrorDefinition>>> = Object.freeze({
|
||||
upstream_timeout: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.upstream_timeout", retryPolicy: "immediate", taskOutcome: "failed", userAction: "retry_same_input" }),
|
||||
upstream_failed: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.upstream_failed", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" }),
|
||||
safety_rejected: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.safety_rejected", retryPolicy: "after_edit", taskOutcome: "rejected", userAction: "edit_input" }),
|
||||
model_disabled: Object.freeze({ creditBehavior: "no_reserve", messageKey: "generation.error.model_disabled", retryPolicy: "after_model_change", taskOutcome: "not_created", userAction: "choose_model_or_contact_admin" }),
|
||||
gateway_balance_insufficient: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.gateway_balance_insufficient", retryPolicy: "after_model_change", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" }),
|
||||
gateway_contract_invalid: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.gateway_contract_invalid", retryPolicy: "after_model_change", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" }),
|
||||
reference_invalid: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.reference_invalid", retryPolicy: "after_edit", taskOutcome: "failed_or_not_created", userAction: "edit_input" }),
|
||||
unknown_retryable: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.unknown_retryable", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" }),
|
||||
unknown_non_retryable: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.unknown_non_retryable", retryPolicy: "none", taskOutcome: "failed", userAction: "contact_admin" }),
|
||||
});
|
||||
|
||||
export function isGenerationErrorCategory(value: unknown): value is GenerationErrorCategory {
|
||||
return typeof value === "string" && generationErrorCategories.includes(value as GenerationErrorCategory);
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
||||
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||
|
||||
const hardLimitBytes = 5_368_709_120;
|
||||
const warningLimitBytes = 4_294_967_296;
|
||||
const criticalLimitBytes = 4_831_838_208;
|
||||
const maximumOutputBytes = 20 * 1_024 * 1_024;
|
||||
const safeSourceCategory = /^[a-z][a-z0-9_]{0,79}$/;
|
||||
|
||||
interface JobRow {
|
||||
config_snapshot_json: string;
|
||||
error_category: GenerationErrorCategory | null;
|
||||
final_credit_state: "committed" | "released" | null;
|
||||
generation_id: string;
|
||||
model_id: string;
|
||||
owner_id: string;
|
||||
project_id: string;
|
||||
prompt: string;
|
||||
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||
heartbeat_at: number | null;
|
||||
lease_expires_at: number | null;
|
||||
lease_owner: string | null;
|
||||
upstream_job_reference: string | null;
|
||||
}
|
||||
|
||||
interface ReservationRow {
|
||||
amount: number;
|
||||
model_id: string;
|
||||
status: "reserved" | "committed" | "released";
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface GenerationProcessingResult {
|
||||
category: GenerationErrorCategory | null;
|
||||
generationId: string;
|
||||
outputAssetId: string | null;
|
||||
projectId: string;
|
||||
status: "running" | "succeeded" | "failed" | "rejected";
|
||||
}
|
||||
|
||||
export interface LeaseRecoveryAction {
|
||||
action: "poll" | "unknown_retryable";
|
||||
generationId: string;
|
||||
}
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function extensionFor(mimeType: NormalizedGenerationOutput["mimeType"]) {
|
||||
return mimeType === "image/png" ? ".png" : mimeType === "image/jpeg" ? ".jpg" : ".webp";
|
||||
}
|
||||
|
||||
function hasExpectedMagic(output: NormalizedGenerationOutput) {
|
||||
const bytes = output.bytes;
|
||||
if (output.mimeType === "image/png") return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||
if (output.mimeType === "image/jpeg") return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
return bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
|
||||
}
|
||||
|
||||
function capacityClass(bytes: number) {
|
||||
return {
|
||||
capacity: bytes < warningLimitBytes ? "normal" : bytes < criticalLimitBytes ? "warning" : "critical",
|
||||
status: bytes >= hardLimitBytes ? "full" : "active",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export class GenerationProcessor {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly adapter: GenerationAdapter;
|
||||
private readonly clock: () => number;
|
||||
private readonly dataRoot: string;
|
||||
private readonly gatewayBalance: GatewayBalanceRuntime;
|
||||
private readonly workerId: string;
|
||||
private closed = false;
|
||||
private readonly lockTimer: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(input: { adapter: GenerationAdapter; clock?: () => number; dataRoot: string; databasePath: string; workerId: string }) {
|
||||
if (!input.workerId) throw new Error("generation_worker_id_required");
|
||||
this.adapter = input.adapter;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.dataRoot = resolve(input.dataRoot);
|
||||
this.workerId = input.workerId;
|
||||
this.database = new Database(input.databasePath);
|
||||
configureWorkerDatabase(this.database);
|
||||
this.migrate();
|
||||
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
||||
try {
|
||||
this.setReady();
|
||||
try {
|
||||
this.recoverExpiredLeases();
|
||||
} catch {
|
||||
this.setDegraded("lease_recovery_failed");
|
||||
}
|
||||
this.lockTimer = setInterval(() => this.renewWorkerLock(), 10_000);
|
||||
} catch (error) {
|
||||
this.database.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
clearInterval(this.lockTimer);
|
||||
this.immediate(() => {
|
||||
const now = this.clock();
|
||||
this.database.prepare("UPDATE worker_runtime_state SET status = 'degraded', reason = ?, owner_id = NULL, lock_expires_at = NULL, updated_at = ? WHERE singleton = 1 AND owner_id = ?")
|
||||
.run("worker_stopped", now, this.workerId);
|
||||
});
|
||||
this.gatewayBalance.close();
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
async processNext() {
|
||||
if (this.readWorkerState().status === "degraded") return undefined;
|
||||
this.recoverExpiredLeases();
|
||||
const row = this.database.prepare(`
|
||||
SELECT generation_id FROM generation_jobs
|
||||
WHERE submission_ready = 1 AND (status = 'queued' OR (status = 'running' AND lease_owner = ?))
|
||||
ORDER BY created_at, generation_id LIMIT 1
|
||||
`).get(this.workerId) as { generation_id: string } | undefined;
|
||||
return row ? this.processGeneration(row.generation_id) : undefined;
|
||||
}
|
||||
|
||||
async processGeneration(generationId: string): Promise<GenerationProcessingResult> {
|
||||
const replay = this.readReceipt(generationId);
|
||||
if (replay) return replay;
|
||||
const job = this.claim(generationId);
|
||||
if (["succeeded", "failed", "rejected"].includes(job.status)) return this.terminalResult(job);
|
||||
|
||||
const references = this.database.prepare(`
|
||||
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||
`).all(generationId) as Array<{ managed_file_id: string }>;
|
||||
let adapterResult: GenerationAdapterResult;
|
||||
try {
|
||||
if (job.upstream_job_reference) {
|
||||
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
|
||||
adapterResult = await this.withHeartbeat(generationId, () => this.adapter.poll!(job.upstream_job_reference!));
|
||||
} else {
|
||||
adapterResult = await this.withHeartbeat(generationId, () => this.adapter.start({
|
||||
configSnapshot: JSON.parse(job.config_snapshot_json) as Record<string, unknown>,
|
||||
generationId,
|
||||
modelId: job.model_id,
|
||||
prompt: job.prompt,
|
||||
ratio: job.ratio,
|
||||
referenceAssetIds: references.map((row) => row.managed_file_id),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
|
||||
}
|
||||
|
||||
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
||||
if (adapterResult.status === "pending") return this.persistPending(job.generation_id, adapterResult.upstreamJobReference);
|
||||
const output = adapterResult.outputs.length === 1 ? adapterResult.outputs[0] : undefined;
|
||||
if (!output || !this.isSafeOutput(job, output)) {
|
||||
return this.completeFailure(job, adapterResult.outputs.length === 1 ? "unknown_retryable" : "gateway_contract_invalid", "output_validation_failed", undefined, true);
|
||||
}
|
||||
try {
|
||||
return this.completeSuccess(job, output);
|
||||
} catch {
|
||||
return this.completeFailure(job, "unknown_retryable", "output_persist_failed", undefined, true);
|
||||
}
|
||||
}
|
||||
|
||||
private claim(generationId: string) {
|
||||
return this.immediate(() => {
|
||||
const row = this.readJob(generationId);
|
||||
if (row.status === "queued") {
|
||||
const now = this.clock();
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE generation_jobs
|
||||
SET status = 'running', lease_owner = ?, lease_expires_at = ?, heartbeat_at = ?, started_at = COALESCE(started_at, ?),
|
||||
attempt_no = attempt_no + 1, updated_at = ?
|
||||
WHERE generation_id = ? AND status = 'queued' AND submission_ready = 1
|
||||
`).run(this.workerId, now + 30_000, now, now, now, generationId);
|
||||
if (changed.changes !== 1) throw new Error("generation_claim_conflict");
|
||||
return this.readJob(generationId);
|
||||
}
|
||||
if (row.status === "running" && row.final_credit_state === null && row.lease_owner === this.workerId && (row.lease_expires_at ?? 0) >= this.clock()) return row;
|
||||
if (row.status === "running" && row.final_credit_state === null) throw new Error("generation_claim_conflict");
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
private async withHeartbeat<T>(generationId: string, action: () => Promise<T>) {
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
this.heartbeat(generationId);
|
||||
} catch {
|
||||
this.setDegraded("heartbeat_failed");
|
||||
}
|
||||
}, 10_000);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private renewWorkerLock() {
|
||||
try {
|
||||
const now = this.clock();
|
||||
this.database.prepare("UPDATE worker_runtime_state SET lock_expires_at = ?, updated_at = ? WHERE singleton = 1 AND owner_id = ?")
|
||||
.run(now + 30_000, now, this.workerId);
|
||||
} catch {
|
||||
if (!this.closed) this.setDegraded("worker_lock_renewal_failed");
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat(generationId: string) {
|
||||
return this.immediate(() => {
|
||||
const now = this.clock();
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE generation_jobs SET heartbeat_at = ?, lease_expires_at = ?, updated_at = ?
|
||||
WHERE generation_id = ? AND status = 'running' AND lease_owner = ?
|
||||
`).run(now, now + 30_000, now, generationId, this.workerId);
|
||||
if (changed.changes !== 1) throw new Error("generation_heartbeat_conflict");
|
||||
this.database.prepare("UPDATE worker_runtime_state SET lock_expires_at = ?, updated_at = ? WHERE singleton = 1 AND owner_id = ?")
|
||||
.run(now + 30_000, now, this.workerId);
|
||||
return { generationId, heartbeat_at: now, lease_expires_at: now + 30_000 };
|
||||
});
|
||||
}
|
||||
|
||||
recoverExpiredLeases(): LeaseRecoveryAction[] {
|
||||
return this.immediate(() => {
|
||||
const now = this.clock();
|
||||
const rows = this.database.prepare(`
|
||||
SELECT * FROM generation_jobs
|
||||
WHERE status = 'running' AND submission_ready = 1 AND lease_expires_at IS NOT NULL AND lease_expires_at < ?
|
||||
ORDER BY created_at, generation_id
|
||||
`).all(now) as JobRow[];
|
||||
const actions: LeaseRecoveryAction[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.upstream_job_reference) {
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs SET lease_owner = ?, lease_expires_at = ?, heartbeat_at = ?, updated_at = ?
|
||||
WHERE generation_id = ? AND status = 'running' AND lease_expires_at < ?
|
||||
`).run(this.workerId, now + 30_000, now, now, row.generation_id, now);
|
||||
actions.push({ action: "poll", generationId: row.generation_id });
|
||||
} else {
|
||||
this.completeFailure(row, "unknown_retryable", "lease_expired_without_reference", undefined, false, "pending_manual_review");
|
||||
actions.push({ action: "unknown_retryable", generationId: row.generation_id });
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
});
|
||||
}
|
||||
|
||||
setDegraded(reason: string) {
|
||||
return this.immediate(() => {
|
||||
const now = this.clock();
|
||||
this.database.prepare("UPDATE worker_runtime_state SET status = 'degraded', reason = ?, updated_at = ? WHERE singleton = 1").run(reason, now);
|
||||
return this.readWorkerState();
|
||||
});
|
||||
}
|
||||
|
||||
setReady() {
|
||||
return this.immediate(() => {
|
||||
const now = this.clock();
|
||||
const current = this.database.prepare("SELECT owner_id, lock_expires_at FROM worker_runtime_state WHERE singleton = 1")
|
||||
.get() as { lock_expires_at: number | null; owner_id: string | null } | undefined;
|
||||
if (current?.owner_id && current.owner_id !== this.workerId && (current.lock_expires_at ?? 0) >= now) throw new Error("worker_instance_lock_conflict");
|
||||
this.database.prepare("UPDATE worker_runtime_state SET status = 'ready', reason = NULL, owner_id = ?, lock_expires_at = ?, updated_at = ? WHERE singleton = 1")
|
||||
.run(this.workerId, now + 30_000, now);
|
||||
return this.readWorkerState();
|
||||
});
|
||||
}
|
||||
|
||||
private readWorkerState() {
|
||||
return this.database.prepare("SELECT status, reason, updated_at FROM worker_runtime_state WHERE singleton = 1").get() as { reason: string | null; status: "ready" | "degraded"; updated_at: number };
|
||||
}
|
||||
|
||||
private persistPending(generationId: string, upstreamJobReference: string): GenerationProcessingResult {
|
||||
return this.immediate(() => {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9:_.-]{0,159}$/.test(upstreamJobReference)) {
|
||||
const job = this.readJob(generationId);
|
||||
return this.completeFailure(job, "gateway_contract_invalid", "upstream_reference_invalid");
|
||||
}
|
||||
const now = this.clock();
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs SET upstream_job_reference = ?, heartbeat_at = ?, lease_expires_at = ?, updated_at = ?
|
||||
WHERE generation_id = ? AND status = 'running' AND lease_owner = ?
|
||||
`).run(upstreamJobReference, now, now + 30_000, now, generationId, this.workerId);
|
||||
const job = this.readJob(generationId);
|
||||
return { category: null, generationId, outputAssetId: null, projectId: job.project_id, status: "running" };
|
||||
});
|
||||
}
|
||||
|
||||
private completeFailure(
|
||||
job: JobRow,
|
||||
category: GenerationErrorCategory,
|
||||
sourceCategory: string,
|
||||
balanceSignal?: { gatewayAccountRef: string; impactScope: "model" | "account" | "unknown" },
|
||||
upstreamOutcomeKnown = false,
|
||||
reconciliation: "not_required" | "pending_manual_review" = upstreamOutcomeKnown ? "pending_manual_review" : "not_required",
|
||||
) {
|
||||
return this.immediate(() => {
|
||||
const replay = this.readReceipt(job.generation_id);
|
||||
if (replay) return replay;
|
||||
const latest = this.readJob(job.generation_id);
|
||||
if (["succeeded", "failed", "rejected"].includes(latest.status)) return this.terminalResult(latest);
|
||||
const status = category === "safety_rejected" ? "rejected" as const : "failed" as const;
|
||||
this.finalizeCredit(job.generation_id, status);
|
||||
if (balanceSignal) {
|
||||
this.gatewayBalance.seedModels([{ gatewayAccountRef: balanceSignal.gatewayAccountRef, modelId: job.model_id }]);
|
||||
this.gatewayBalance.recordInsufficient({
|
||||
eventId: `generation-${job.generation_id}`,
|
||||
gatewayAccountRef: balanceSignal.gatewayAccountRef,
|
||||
impactScope: balanceSignal.impactScope,
|
||||
modelId: job.model_id,
|
||||
sourceCategory: "adapter_balance_signal",
|
||||
});
|
||||
}
|
||||
const now = this.clock();
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs
|
||||
SET status = ?, error_category = ?, diagnostic_source_category = ?, final_credit_state = 'released',
|
||||
upstream_outcome_known = ?, upstream_cost_reconciliation = ?, finished_at = ?, updated_at = ?,
|
||||
lease_owner = NULL, lease_expires_at = NULL, heartbeat_at = NULL
|
||||
WHERE generation_id = ? AND status = 'running'
|
||||
`).run(
|
||||
status,
|
||||
category,
|
||||
safeSourceCategory.test(sourceCategory) ? sourceCategory : "adapter_error",
|
||||
upstreamOutcomeKnown ? 1 : 0,
|
||||
reconciliation,
|
||||
now,
|
||||
now,
|
||||
job.generation_id,
|
||||
);
|
||||
const result = this.terminalResult(this.readJob(job.generation_id));
|
||||
this.writeReceipt(result, now);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private completeSuccess(job: JobRow, output: NormalizedGenerationOutput) {
|
||||
const fileId = randomUUID();
|
||||
const operationId = randomUUID();
|
||||
const extension = extensionFor(output.mimeType);
|
||||
const relativePath = `content/generated/${job.owner_id}/${fileId}${extension}`;
|
||||
const destinationPath = resolve(this.dataRoot, relativePath);
|
||||
const stagingDirectory = resolve(this.dataRoot, "staging", operationId);
|
||||
const stagingPath = join(stagingDirectory, "payload.tmp");
|
||||
if (!destinationPath.startsWith(`${this.dataRoot}\\`) && !destinationPath.startsWith(`${this.dataRoot}/`)) throw new Error("generation_output_path_invalid");
|
||||
|
||||
this.reserveStorage(operationId, output.bytes.byteLength);
|
||||
let renamed = false;
|
||||
try {
|
||||
mkdirSync(stagingDirectory, { recursive: true });
|
||||
writeFileSync(stagingPath, output.bytes, { flag: "wx" });
|
||||
mkdirSync(dirname(destinationPath), { recursive: true });
|
||||
renameSync(stagingPath, destinationPath);
|
||||
renamed = true;
|
||||
rmSync(stagingDirectory, { force: true, recursive: true });
|
||||
return this.immediate(() => {
|
||||
const replay = this.readReceipt(job.generation_id);
|
||||
if (replay) return replay;
|
||||
const now = this.clock();
|
||||
const project = this.database.prepare("SELECT name, state_version, current_image_id FROM projects WHERE project_id = ? AND status = 'active'")
|
||||
.get(job.project_id) as { current_image_id: string | null; name: string; state_version: number } | undefined;
|
||||
if (!project) throw new Error("generation_project_unavailable");
|
||||
const state = this.database.prepare("SELECT canvas_json FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1")
|
||||
.get(job.project_id) as { canvas_json: string } | undefined;
|
||||
if (!state) throw new Error("generation_project_state_unavailable");
|
||||
const canvas = JSON.parse(state.canvas_json) as { background: { asset_id: string | null }; [key: string]: unknown };
|
||||
if (project.current_image_id === null) canvas.background.asset_id = fileId;
|
||||
this.database.prepare(`
|
||||
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||
VALUES (?, 'generated', ?, ?, ?, ?, ?, 'committed', ?)
|
||||
`).run(fileId, job.owner_id, relativePath, output.bytes.byteLength, output.mimeType, createHash("sha256").update(output.bytes).digest("hex"), iso(now));
|
||||
this.database.prepare("INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'generated', ?)")
|
||||
.run(job.project_id, fileId, now);
|
||||
this.database.prepare("INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)")
|
||||
.run(`project:${job.project_id}:${fileId}`, fileId, iso(now));
|
||||
this.database.prepare(`
|
||||
INSERT INTO generation_output_assets (generation_id, managed_file_id, pixel_width, pixel_height, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(job.generation_id, fileId, output.pixelWidth, output.pixelHeight, now);
|
||||
this.database.prepare("INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(fileId, job.project_id, job.generation_id, now);
|
||||
this.database.prepare(`
|
||||
UPDATE projects SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
|
||||
WHERE project_id = ?
|
||||
`).run(fileId, now, job.project_id);
|
||||
this.database.prepare("INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(job.project_id, project.state_version + 1, project.name, JSON.stringify(canvas), now);
|
||||
this.consumeStorage(operationId, output.bytes.byteLength, now);
|
||||
this.finalizeCredit(job.generation_id, "succeeded");
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs
|
||||
SET status = 'succeeded', error_category = NULL, final_credit_state = 'committed', output_asset_id = ?,
|
||||
upstream_outcome_known = 1, upstream_cost_reconciliation = 'not_required', finished_at = ?, updated_at = ?,
|
||||
lease_owner = NULL, lease_expires_at = NULL, heartbeat_at = NULL
|
||||
WHERE generation_id = ? AND status = 'running'
|
||||
`).run(fileId, now, now, job.generation_id);
|
||||
const result = this.terminalResult(this.readJob(job.generation_id));
|
||||
this.writeReceipt(result, now);
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
if (renamed && existsSync(destinationPath)) this.queueCompensation(relativePath, output.bytes.byteLength);
|
||||
else rmSync(stagingDirectory, { force: true, recursive: true });
|
||||
this.releaseStorage(operationId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isSafeOutput(job: JobRow, output: NormalizedGenerationOutput) {
|
||||
const pixels = { "1:1": [1080, 1080], "3:4": [1080, 1440], "4:3": [1440, 1080], "9:16": [1080, 1920] }[job.ratio];
|
||||
return output.bytes.byteLength > 0 && output.bytes.byteLength <= maximumOutputBytes && hasExpectedMagic(output)
|
||||
&& output.pixelWidth === pixels[0] && output.pixelHeight === pixels[1];
|
||||
}
|
||||
|
||||
private finalizeCredit(generationId: string, outcome: "succeeded" | "failed" | "rejected") {
|
||||
const reservation = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
|
||||
.get(generationId) as ReservationRow | undefined;
|
||||
if (!reservation) throw new Error("generation_credit_reservation_missing");
|
||||
if (reservation.status !== "reserved") return;
|
||||
const account = this.database.prepare("SELECT available_balance, reserved_balance FROM credit_accounts WHERE user_id = ?")
|
||||
.get(reservation.user_id) as { available_balance: number; reserved_balance: number } | undefined;
|
||||
if (!account || account.reserved_balance < reservation.amount) throw new Error("generation_credit_invariant_failed");
|
||||
const committed = outcome === "succeeded";
|
||||
const availableAfter = committed ? account.available_balance : account.available_balance + reservation.amount;
|
||||
const reservedAfter = account.reserved_balance - reservation.amount;
|
||||
const now = this.clock();
|
||||
const operationKey = `generation:${generationId}:${committed ? "commit" : "release"}`;
|
||||
this.database.prepare("UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? WHERE user_id = ?")
|
||||
.run(availableAfter, reservedAfter, now, reservation.user_id);
|
||||
this.database.prepare("UPDATE credit_reservations SET status = ?, finalized_at = ? WHERE generation_id = ? AND status = 'reserved'")
|
||||
.run(committed ? "committed" : "released", now, generationId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_ledger (
|
||||
ledger_id, user_id, operation_key, entry_type, amount, available_before, available_after,
|
||||
reserved_before, reserved_after, created_at, reference_type, reference_id, model_id, reason, entry_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'generation', ?, ?, NULL, ?)
|
||||
`).run(
|
||||
randomUUID(), reservation.user_id, operationKey, committed ? "generation_commit" : "generation_release",
|
||||
committed ? -reservation.amount : reservation.amount, account.available_balance, availableAfter,
|
||||
account.reserved_balance, reservedAfter, now, generationId, reservation.model_id, committed ? "committed" : "released",
|
||||
);
|
||||
this.database.prepare(`
|
||||
INSERT INTO outbox_events (event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at)
|
||||
VALUES (?, ?, ?, 'generation', ?, ?, 'pending', ?, NULL)
|
||||
`).run(randomUUID(), operationKey, committed ? "generation_credit_committed" : "generation_credit_released", generationId, JSON.stringify({ outcome }), now);
|
||||
}
|
||||
|
||||
private reserveStorage(operationId: string, bytes: number) {
|
||||
this.immediate(() => {
|
||||
const state = this.database.prepare("SELECT managed_content_bytes, storage_status FROM local_backend_storage_state WHERE singleton = 1")
|
||||
.get() as { managed_content_bytes: number; storage_status: string } | undefined;
|
||||
const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'")
|
||||
.get() as { bytes: number };
|
||||
if (!state || state.storage_status !== "active" || state.managed_content_bytes + active.bytes + bytes > hardLimitBytes) {
|
||||
throw new Error("generation_storage_unavailable");
|
||||
}
|
||||
this.database.prepare("INSERT INTO storage_reservations (reservation_id, operation_id, projected_bytes, status, created_at) VALUES (?, ?, ?, 'active', ?)")
|
||||
.run(randomUUID(), operationId, bytes, iso(this.clock()));
|
||||
});
|
||||
}
|
||||
|
||||
private consumeStorage(operationId: string, bytes: number, now: number) {
|
||||
this.database.prepare("UPDATE storage_reservations SET status = 'consumed', resolved_at = ? WHERE operation_id = ? AND status = 'active'")
|
||||
.run(iso(now), operationId);
|
||||
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1")
|
||||
.get() as { managed_content_bytes: number };
|
||||
const next = state.managed_content_bytes + bytes;
|
||||
const classification = capacityClass(next);
|
||||
this.database.prepare(`
|
||||
UPDATE local_backend_storage_state
|
||||
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||
WHERE singleton = 1
|
||||
`).run(next, classification.capacity, classification.status, iso(now));
|
||||
}
|
||||
|
||||
private releaseStorage(operationId: string) {
|
||||
try {
|
||||
this.database.prepare("UPDATE storage_reservations SET status = 'released', resolved_at = ? WHERE operation_id = ? AND status = 'active'")
|
||||
.run(iso(this.clock()), operationId);
|
||||
} catch {
|
||||
// Startup storage reconciliation remains the fallback if SQLite is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private queueCompensation(relativePath: string, bytes: number) {
|
||||
try {
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, reason, status, created_at
|
||||
) VALUES (?, NULL, ?, ?, 0, 'compensation', 'pending', ?)
|
||||
`).run(randomUUID(), relativePath, bytes, iso(this.clock()));
|
||||
} catch {
|
||||
// Startup reconciliation detects unindexed generated files as a final fallback.
|
||||
}
|
||||
}
|
||||
|
||||
private readJob(generationId: string) {
|
||||
const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1").get(generationId) as JobRow | undefined;
|
||||
if (!row) throw new Error("generation_not_found");
|
||||
return row;
|
||||
}
|
||||
|
||||
private terminalResult(row: JobRow): GenerationProcessingResult {
|
||||
if (!["succeeded", "failed", "rejected"].includes(row.status)) throw new Error("generation_not_terminal");
|
||||
const output = this.database.prepare("SELECT managed_file_id FROM generation_output_assets WHERE generation_id = ?")
|
||||
.get(row.generation_id) as { managed_file_id: string } | undefined;
|
||||
return {
|
||||
category: row.error_category,
|
||||
generationId: row.generation_id,
|
||||
outputAssetId: output?.managed_file_id ?? null,
|
||||
projectId: row.project_id,
|
||||
status: row.status as GenerationProcessingResult["status"],
|
||||
};
|
||||
}
|
||||
|
||||
private readReceipt(generationId: string) {
|
||||
const row = this.database.prepare("SELECT response_json FROM generation_processor_receipts WHERE generation_id = ?")
|
||||
.get(generationId) as { response_json: string } | undefined;
|
||||
return row ? JSON.parse(row.response_json) as GenerationProcessingResult : undefined;
|
||||
}
|
||||
|
||||
private writeReceipt(result: GenerationProcessingResult, now: number) {
|
||||
this.database.prepare("INSERT INTO generation_processor_receipts (generation_id, response_json, created_at) VALUES (?, ?, ?)")
|
||||
.run(result.generationId, JSON.stringify(result), now);
|
||||
}
|
||||
|
||||
private immediate<T>(action: () => T) {
|
||||
if (this.database.inTransaction) return action();
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
this.database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureColumn(table: string, column: string, definition: string) {
|
||||
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (!columns.some((entry) => entry.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.ensureColumn("generation_jobs", "lease_owner", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "lease_expires_at", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "heartbeat_at", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "started_at", "INTEGER");
|
||||
this.ensureColumn("generation_jobs", "attempt_no", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.ensureColumn("generation_jobs", "output_asset_id", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "diagnostic_source_category", "TEXT");
|
||||
this.ensureColumn("generation_jobs", "upstream_outcome_known", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.ensureColumn("generation_jobs", "upstream_cost_reconciliation", "TEXT NOT NULL DEFAULT 'not_applicable'");
|
||||
this.ensureColumn("generation_jobs", "upstream_job_reference", "TEXT");
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS worker_runtime_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
status TEXT NOT NULL CHECK (status IN ('ready', 'degraded')),
|
||||
reason TEXT,
|
||||
owner_id TEXT,
|
||||
lock_expires_at INTEGER,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation_output_assets (
|
||||
generation_id TEXT PRIMARY KEY REFERENCES generation_jobs(generation_id),
|
||||
managed_file_id TEXT NOT NULL UNIQUE REFERENCES managed_files(file_id),
|
||||
pixel_width INTEGER NOT NULL CHECK (pixel_width > 0),
|
||||
pixel_height INTEGER NOT NULL CHECK (pixel_height > 0),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation_processor_receipts (
|
||||
generation_id TEXT PRIMARY KEY REFERENCES generation_jobs(generation_id),
|
||||
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
this.ensureColumn("worker_runtime_state", "owner_id", "TEXT");
|
||||
this.ensureColumn("worker_runtime_state", "lock_expires_at", "INTEGER");
|
||||
this.database.prepare("INSERT OR IGNORE INTO worker_runtime_state (singleton, status, reason, owner_id, lock_expires_at, updated_at) VALUES (1, 'ready', NULL, NULL, NULL, ?)").run(this.clock());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||
import type { GenerationErrorCategory } from "./generation-error-registry.js";
|
||||
|
||||
export type AdapterStartResult =
|
||||
| { outputs: readonly NormalizedGenerationOutput[]; status: "completed" }
|
||||
| { status: "pending"; upstreamJobReference: string }
|
||||
| { category: GenerationErrorCategory; sourceCategory: string; status: "failed" };
|
||||
|
||||
export interface AdapterTransportRequest {
|
||||
operation: "start" | "poll";
|
||||
modelId: string;
|
||||
prompt?: string;
|
||||
ratio?: GenerationAdapterRequest["ratio"];
|
||||
referenceAssetIds?: readonly string[];
|
||||
upstreamJobReference?: string;
|
||||
}
|
||||
|
||||
export interface AdapterTransport {
|
||||
start(request: AdapterTransportRequest): Promise<unknown>;
|
||||
poll(request: AdapterTransportRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ContractValidationResult {
|
||||
contractVersion: string;
|
||||
evidenceRef: string;
|
||||
fixtureId: string;
|
||||
modelId: string;
|
||||
reason?: "contract_profile_mismatch";
|
||||
status: "blocked" | "verified";
|
||||
}
|
||||
|
||||
export interface AdapterBalanceSignal {
|
||||
gatewayAccountRef: string;
|
||||
impactScope: "model" | "account" | "unknown";
|
||||
}
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly contractVersion: string;
|
||||
readonly fixtureId: string;
|
||||
readonly modelId: string;
|
||||
checkBalanceSignal(response: unknown): AdapterBalanceSignal | undefined;
|
||||
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>): { category: GenerationErrorCategory; sourceCategory: string };
|
||||
normalizeOutput(response: unknown): NormalizedGenerationOutput;
|
||||
poll(upstreamJobReference: string): Promise<AdapterStartResult>;
|
||||
start(request: GenerationAdapterRequest): Promise<AdapterStartResult>;
|
||||
validateContract(routeProfile: Readonly<Record<string, unknown>>): ContractValidationResult;
|
||||
}
|
||||
|
||||
export class AdapterContractError extends Error {
|
||||
constructor(readonly sourceCategory: string, message = sourceCategory) {
|
||||
super(message);
|
||||
this.name = "AdapterContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export const supportedAdapterRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
|
||||
|
||||
export function dimensionsForRatio(ratio: GenerationAdapterRequest["ratio"]) {
|
||||
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
|
||||
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
|
||||
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
|
||||
: { pixelHeight: 1920, pixelWidth: 1080 };
|
||||
}
|
||||
|
||||
export function mockPngBytes(seed: string) {
|
||||
const body = Buffer.from(`DADA-MOCK-${seed}`, "ascii");
|
||||
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), body]);
|
||||
}
|
||||
|
||||
export function validateAdapterRequest(request: GenerationAdapterRequest, modelId: string) {
|
||||
if (request.modelId !== modelId) throw new AdapterContractError("model_id_mismatch");
|
||||
if (!supportedAdapterRatios.includes(request.ratio)) throw new AdapterContractError("ratio_unsupported");
|
||||
if (request.referenceAssetIds.length > 2) throw new AdapterContractError("reference_count_exceeded");
|
||||
if (!request.prompt.trim()) throw new AdapterContractError("prompt_empty");
|
||||
}
|
||||
|
||||
const errorCategories = new Set<GenerationErrorCategory>([
|
||||
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
|
||||
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
|
||||
"unknown_retryable", "unknown_non_retryable",
|
||||
]);
|
||||
|
||||
export function classifyAdapterError(error: unknown, mappingProfile: Readonly<Record<string, string>>) {
|
||||
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "unknown";
|
||||
const mapped = mappingProfile[code] ?? (code === "timeout" ? "upstream_timeout" : code === "safety" ? "safety_rejected" : "unknown_retryable");
|
||||
const category = errorCategories.has(mapped as GenerationErrorCategory) ? mapped as GenerationErrorCategory : "unknown_retryable";
|
||||
const sourceCategory = /^[a-z][a-z0-9_]{0,79}$/.test(code) ? code : "adapter_error";
|
||||
return { category, sourceCategory };
|
||||
}
|
||||
|
||||
export function balanceSignalFromResponse(response: unknown): AdapterBalanceSignal | undefined {
|
||||
if (!response || typeof response !== "object" || !("balance_signal" in response)) return undefined;
|
||||
const signal = response.balance_signal;
|
||||
if (!signal || typeof signal !== "object") return undefined;
|
||||
const gatewayAccountRef = "gateway_account_ref" in signal && typeof signal.gateway_account_ref === "string" ? signal.gateway_account_ref : "";
|
||||
const impactScope = "impact_scope" in signal && (signal.impact_scope === "model" || signal.impact_scope === "account" || signal.impact_scope === "unknown")
|
||||
? signal.impact_scope : undefined;
|
||||
return gatewayAccountRef && impactScope ? { gatewayAccountRef, impactScope } : undefined;
|
||||
}
|
||||
|
||||
export function validateMockContract(
|
||||
routeProfile: Readonly<Record<string, unknown>>,
|
||||
expected: Readonly<Record<string, unknown>>,
|
||||
modelId: string,
|
||||
fixtureId: string,
|
||||
contractVersion: string,
|
||||
): ContractValidationResult {
|
||||
const matches = Object.entries(expected).every(([key, value]) => JSON.stringify(routeProfile[key]) === JSON.stringify(value));
|
||||
return matches
|
||||
? { contractVersion, evidenceRef: `${fixtureId}:mock`, fixtureId, modelId, status: "verified" }
|
||||
: { contractVersion, evidenceRef: `${fixtureId}:mock`, fixtureId, modelId, reason: "contract_profile_mismatch", status: "blocked" };
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
interface ProjectCleanupRow {
|
||||
cleanup_id: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
interface ExpiredProjectRow {
|
||||
owner_id: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
interface FileCleanupRow {
|
||||
byte_size: number;
|
||||
cleanup_id: string;
|
||||
counts_toward_managed: 0 | 1;
|
||||
managed_file_id: string | null;
|
||||
relative_path: string;
|
||||
}
|
||||
|
||||
const resourceScope = JSON.stringify([
|
||||
"project_state", "generation", "generated_image", "reference", "location", "latest_export",
|
||||
]);
|
||||
|
||||
function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
export class ProjectPurgeCleanup {
|
||||
private readonly clock: () => number;
|
||||
private readonly dataRoot: string | undefined;
|
||||
private readonly database: BetterSqlite3.Database;
|
||||
|
||||
constructor(input: { clock?: () => number; dataRoot?: string; databasePath: string }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.dataRoot = input.dataRoot ? resolve(input.dataRoot) : undefined;
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
run() {
|
||||
const expired = this.sweepExpired();
|
||||
const relational = this.processPending();
|
||||
const physical = this.processFileCleanup();
|
||||
return { expired, physical, relational };
|
||||
}
|
||||
|
||||
sweepExpired() {
|
||||
if (!this.tableExists("projects") || !this.tableExists("project_cleanup_queue")) return 0;
|
||||
const now = this.clock();
|
||||
const rows = this.database.prepare(`
|
||||
SELECT project_id, owner_id FROM projects
|
||||
WHERE status = 'trashed' AND purge_at <= ?
|
||||
ORDER BY purge_at, project_id
|
||||
`).all(now) as ExpiredProjectRow[];
|
||||
let expired = 0;
|
||||
for (const row of rows) {
|
||||
const transaction = this.database.transaction(() => {
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1
|
||||
WHERE project_id = ? AND status = 'trashed'
|
||||
`).run(now, row.project_id);
|
||||
if (changed.changes !== 1) return false;
|
||||
this.queueManagedFiles(row.project_id, now);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO project_cleanup_queue (
|
||||
cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error
|
||||
) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL)
|
||||
`).run(randomUUID(), row.project_id, row.owner_id, resourceScope, now);
|
||||
return true;
|
||||
});
|
||||
if (transaction.immediate()) expired += 1;
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
processPending() {
|
||||
if (!this.tableExists("project_cleanup_queue") || !this.tableExists("projects")) return { completed: 0, failed: 0 };
|
||||
const rows = this.database.prepare(`
|
||||
SELECT cleanup_id, project_id FROM project_cleanup_queue
|
||||
WHERE status IN ('pending', 'failed') ORDER BY created_at, project_id
|
||||
`).all() as ProjectCleanupRow[];
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const transaction = this.database.transaction(() => {
|
||||
this.database.prepare("DELETE FROM projects WHERE project_id = ? AND status = 'purged'").run(row.project_id);
|
||||
this.database.prepare(`
|
||||
UPDATE project_cleanup_queue
|
||||
SET status = 'completed', completed_at = ?, last_error = NULL
|
||||
WHERE cleanup_id = ?
|
||||
`).run(this.clock(), row.cleanup_id);
|
||||
});
|
||||
transaction.immediate();
|
||||
completed += 1;
|
||||
} catch {
|
||||
this.database.prepare(`
|
||||
UPDATE project_cleanup_queue SET status = 'failed', last_error = 'project_cleanup_failed'
|
||||
WHERE cleanup_id = ?
|
||||
`).run(row.cleanup_id);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { completed, failed };
|
||||
}
|
||||
|
||||
processFileCleanup() {
|
||||
if (!this.dataRoot || !this.tableExists("file_cleanup_queue") || !this.tableExists("managed_files")) {
|
||||
return { completed: 0, failed: 0 };
|
||||
}
|
||||
const rows = this.database.prepare(`
|
||||
SELECT cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed
|
||||
FROM file_cleanup_queue WHERE status IN ('pending', 'failed') ORDER BY created_at, cleanup_id
|
||||
`).all() as FileCleanupRow[];
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const path = this.resolveManagedPath(row.relative_path);
|
||||
rmSync(path, { force: true });
|
||||
const transaction = this.database.transaction(() => {
|
||||
if (row.counts_toward_managed === 1 && row.managed_file_id) {
|
||||
if (this.tableExists("project_asset_refs")) {
|
||||
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||
}
|
||||
if (this.tableExists("project_resource_files")) {
|
||||
this.database.prepare("DELETE FROM project_resource_files WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||
}
|
||||
if (this.tableExists("asset_cleanup_request_items")) {
|
||||
const requests = this.database.prepare("SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ?")
|
||||
.all(row.managed_file_id) as Array<{ request_id: string }>;
|
||||
this.database.prepare("DELETE FROM asset_cleanup_request_items WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||
if (this.tableExists("asset_cleanup_requests")) {
|
||||
for (const request of requests) {
|
||||
const pending = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?")
|
||||
.get(request.request_id) as { count: number };
|
||||
if (pending.count === 0) {
|
||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'completed' WHERE request_id = ? AND status = 'queued'")
|
||||
.run(request.request_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id);
|
||||
if (this.tableExists("local_backend_storage_state")) this.decrementManagedCapacity(row.byte_size);
|
||||
}
|
||||
this.database.prepare(`
|
||||
UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL
|
||||
WHERE cleanup_id = ?
|
||||
`).run(iso(this.clock()), row.cleanup_id);
|
||||
});
|
||||
transaction.immediate();
|
||||
completed += 1;
|
||||
} catch {
|
||||
this.database.prepare(`
|
||||
UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed'
|
||||
WHERE cleanup_id = ?
|
||||
`).run(row.cleanup_id);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { completed, failed };
|
||||
}
|
||||
|
||||
private queueManagedFiles(projectId: string, now: number) {
|
||||
if (!["project_resource_files", "managed_files", "project_asset_refs", "file_cleanup_queue"]
|
||||
.every((table) => this.tableExists(table))) return;
|
||||
const files = this.database.prepare(`
|
||||
SELECT mf.file_id, mf.relative_path, mf.byte_size
|
||||
FROM project_resource_files prf
|
||||
JOIN managed_files mf ON mf.file_id = prf.managed_file_id
|
||||
WHERE prf.project_id = ? AND mf.status = 'committed'
|
||||
ORDER BY mf.file_id
|
||||
`).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
|
||||
for (const file of files) {
|
||||
this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?")
|
||||
.run(`project:${projectId}:${file.file_id}`);
|
||||
const otherProject = this.database.prepare(`
|
||||
SELECT 1 FROM project_resource_files prf
|
||||
JOIN projects p ON p.project_id = prf.project_id
|
||||
WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged'
|
||||
LIMIT 1
|
||||
`).get(file.file_id, projectId);
|
||||
const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id);
|
||||
if (otherProject || otherReference) continue;
|
||||
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'")
|
||||
.run(iso(now), file.file_id);
|
||||
this.database.prepare(`
|
||||
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||
reason, status, created_at, completed_at, last_error
|
||||
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
|
||||
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now));
|
||||
}
|
||||
}
|
||||
|
||||
private tableExists(name: string) {
|
||||
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
}
|
||||
|
||||
private resolveManagedPath(relativePath: string) {
|
||||
const path = resolve(this.dataRoot!, relativePath);
|
||||
const scoped = relative(this.dataRoot!, path);
|
||||
if (!scoped || scoped.startsWith("..") || isAbsolute(scoped)) throw new Error("managed_path_outside_data_root");
|
||||
return path;
|
||||
}
|
||||
|
||||
private decrementManagedCapacity(bytes: number) {
|
||||
const current = this.database.prepare(`
|
||||
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
|
||||
`).get() as { managed_content_bytes: number } | undefined;
|
||||
if (!current) return;
|
||||
const managed = Math.max(0, current.managed_content_bytes - bytes);
|
||||
const notice = managed < 4_294_967_296 ? "normal" : managed < 4_831_838_208 ? "warning" : "critical";
|
||||
const reservations = this.tableExists("storage_reservations")
|
||||
? (this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number }).bytes
|
||||
: 0;
|
||||
const status = managed + reservations >= 5_368_709_120 ? "full" : "active";
|
||||
this.database.prepare(`
|
||||
UPDATE local_backend_storage_state
|
||||
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||
WHERE singleton = 1
|
||||
`).run(managed, notice, status, iso(this.clock()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
export class RetentionCleanup {
|
||||
private readonly clock: () => number;
|
||||
private readonly database: BetterSqlite3.Database;
|
||||
private retentionPurgeActive = false;
|
||||
private retentionPurgeNow = 0;
|
||||
|
||||
constructor(input: { clock?: () => number; databasePath: string }) {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => this.retentionPurgeActive ? 1 : 0);
|
||||
this.database.function("dada_retention_purge_now", { deterministic: false }, () => this.retentionPurgeNow);
|
||||
}
|
||||
|
||||
purgeExpired() {
|
||||
const now = this.clock();
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
this.retentionPurgeActive = true;
|
||||
this.retentionPurgeNow = now;
|
||||
const adminOperations = this.database.prepare("DELETE FROM admin_operation_logs WHERE expires_at <= ?").run(now);
|
||||
const privateAccess = this.database.prepare("DELETE FROM private_content_access_logs WHERE expires_at <= ?").run(now);
|
||||
const anonymous = this.database.prepare("DELETE FROM anonymous_retained_events WHERE expires_at <= ?").run(now);
|
||||
this.retentionPurgeActive = false;
|
||||
this.retentionPurgeNow = 0;
|
||||
this.database.exec("COMMIT");
|
||||
return {
|
||||
admin_operation_logs: adminOperations.changes,
|
||||
anonymous_events: anonymous.changes,
|
||||
private_access_logs: privateAccess.changes,
|
||||
};
|
||||
} catch (error) {
|
||||
this.retentionPurgeActive = false;
|
||||
this.retentionPurgeNow = 0;
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||
const safeStringPattern = /^[A-Za-z0-9_.:@-]{1,160}$/;
|
||||
const forbiddenKeys = new Set([
|
||||
"absolute_path", "api_key", "body", "code_hmac", "content", "credential", "email", "image",
|
||||
"image_content", "password", "path", "prompt", "secret", "session_token", "verification_code", "whitelist",
|
||||
]);
|
||||
const forbiddenKeyFragments = ["content", "credential", "email", "image", "password", "path", "prompt", "secret", "token"];
|
||||
|
||||
function safeSummaryValue(value: unknown, depth: number): boolean {
|
||||
if (depth > 3) return false;
|
||||
if (value === null || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isSafeInteger(value);
|
||||
if (typeof value === "string") return safeStringPattern.test(value) && !value.includes("@");
|
||||
if (Array.isArray(value)) return value.length <= 20 && value.every((entry) => safeSummaryValue(entry, depth + 1));
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entries = Object.entries(value);
|
||||
return entries.length <= 32 && entries.every(([key, entry]) => (
|
||||
auditRefPattern.test(key)
|
||||
&& !forbiddenKeys.has(key.toLowerCase())
|
||||
&& !forbiddenKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||
&& safeSummaryValue(entry, depth + 1)
|
||||
));
|
||||
}
|
||||
|
||||
export function configureWorkerDatabase(database: BetterSqlite3.Database) {
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.pragma("foreign_keys = ON");
|
||||
database.pragma("synchronous = FULL");
|
||||
database.pragma("busy_timeout = 5000");
|
||||
database.function("dada_audit_ref_is_safe", { deterministic: true }, (value: unknown) => (
|
||||
typeof value === "string" && auditRefPattern.test(value) ? 1 : 0
|
||||
));
|
||||
database.function("dada_audit_summary_is_safe", { deterministic: true }, (value: unknown) => {
|
||||
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
|
||||
try {
|
||||
return safeSummaryValue(JSON.parse(value), 0) ? 1 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||
database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||
database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||
database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
||||
|
||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||
import { RetentionCleanup } from "./retention-cleanup.js";
|
||||
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||
import { WorkerStorageStatus } from "./storage-status.js";
|
||||
import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js";
|
||||
@@ -26,14 +28,35 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||
const keepAlive = setInterval(() => undefined, 30_000);
|
||||
let storage: WorkerStorageStatus | undefined;
|
||||
let retention: RetentionCleanup | undefined;
|
||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||
clearInterval(keepAlive);
|
||||
if (retentionTimer) clearInterval(retentionTimer);
|
||||
retention?.close();
|
||||
projectCleanup?.close();
|
||||
storage?.close();
|
||||
});
|
||||
let storageStatus: "active" | "unavailable" = "active";
|
||||
try {
|
||||
const dataRoot = readConfiguredLocalDataRoot();
|
||||
storage = new WorkerStorageStatus(join(dataRoot, "db", "dada.sqlite3"));
|
||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||
storage = new WorkerStorageStatus(databasePath);
|
||||
retention = new RetentionCleanup({ databasePath });
|
||||
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
||||
const runRetentionCleanup = () => {
|
||||
try {
|
||||
retention?.purgeExpired();
|
||||
projectCleanup?.run();
|
||||
} catch {
|
||||
storageStatus = "unavailable";
|
||||
storage?.markLogUnavailable();
|
||||
control.reportStatus("storage_unavailable");
|
||||
}
|
||||
};
|
||||
runRetentionCleanup();
|
||||
retentionTimer = setInterval(runRetentionCleanup, 24 * 60 * 60 * 1_000);
|
||||
const logger = new StructuredJsonlLogger({
|
||||
component: "worker",
|
||||
directory: join(dataRoot, "logs", "worker"),
|
||||
|
||||
+10664
File diff suppressed because it is too large
Load Diff
+59
-3
@@ -10,11 +10,11 @@
|
||||
"build": "pnpm -r --if-present build && dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --configuration Release",
|
||||
"typecheck": "pnpm -r --if-present typecheck",
|
||||
"test:unit:contract": "node --test tests/toolchain/frozen-toolchain.test.mjs",
|
||||
"test:unit": "pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit",
|
||||
"test:unit": "pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/template-registry build && pnpm --filter @dada/asset-renderer build && pnpm --filter @dada/asset-compiler build && pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts --config playwright.config.ts",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts --config playwright.config.ts",
|
||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||
@@ -39,7 +39,62 @@
|
||||
"test:wp0-08": "node scripts/run-wp0-08-validation.mjs",
|
||||
"test:wp0-08:red": "node scripts/run-wp0-08-validation.mjs --phase red",
|
||||
"test:wp0-09": "node scripts/run-wp0-09-validation.mjs",
|
||||
"test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red"
|
||||
"test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red",
|
||||
"test:wp1-01": "node scripts/run-wp1-01-validation.mjs",
|
||||
"test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red",
|
||||
"test:wp1-02": "node scripts/run-wp1-02-validation.mjs",
|
||||
"test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red",
|
||||
"test:wp1-03": "node scripts/run-wp1-03-validation.mjs",
|
||||
"test:wp1-03:red": "node scripts/run-wp1-03-validation.mjs --phase red",
|
||||
"test:wp1-04": "node scripts/run-wp1-04-validation.mjs",
|
||||
"test:wp1-04:red": "node scripts/run-wp1-04-validation.mjs --phase red",
|
||||
"test:wp1-05": "node scripts/run-wp1-05-validation.mjs",
|
||||
"test:wp1-05:red": "node scripts/run-wp1-05-validation.mjs --phase red",
|
||||
"test:wp1-06": "node scripts/run-wp1-06-validation.mjs",
|
||||
"test:wp1-06:red": "node scripts/run-wp1-06-validation.mjs --phase red",
|
||||
"test:wp2-01": "node scripts/run-wp2-01-validation.mjs",
|
||||
"test:wp2-01:red": "node scripts/run-wp2-01-validation.mjs --phase red",
|
||||
"test:wp2-02": "node scripts/run-wp2-02-validation.mjs",
|
||||
"test:wp2-02:red": "node scripts/run-wp2-02-validation.mjs --phase red",
|
||||
"test:wp2-03": "node scripts/run-wp2-03-validation.mjs",
|
||||
"test:wp2-03:red": "node scripts/run-wp2-03-validation.mjs --phase red",
|
||||
"test:wp2-04": "node scripts/run-wp2-04-validation.mjs",
|
||||
"test:wp2-04:red": "node scripts/run-wp2-04-validation.mjs --phase red",
|
||||
"test:wp2-05": "node scripts/run-wp2-05-validation.mjs",
|
||||
"test:wp2-05:red": "node scripts/run-wp2-05-validation.mjs --phase red",
|
||||
"test:wp2-06": "node scripts/run-wp2-06-validation.mjs",
|
||||
"test:wp2-06:red": "node scripts/run-wp2-06-validation.mjs --phase red",
|
||||
"test:wp2-07": "node scripts/run-wp2-07-validation.mjs",
|
||||
"test:wp2-07:red": "node scripts/run-wp2-07-validation.mjs --phase red",
|
||||
"test:wp3-01": "node scripts/run-wp3-01-validation.mjs",
|
||||
"test:wp3-01:red": "node scripts/run-wp3-01-validation.mjs --phase red",
|
||||
"test:wp3-02": "node scripts/run-wp3-02-validation.mjs",
|
||||
"test:wp3-02:red": "node scripts/run-wp3-02-validation.mjs --phase red",
|
||||
"test:wp3-03": "node scripts/run-wp3-03-validation.mjs",
|
||||
"test:wp3-03:red": "node scripts/run-wp3-03-validation.mjs --phase red",
|
||||
"test:wp3-04": "node scripts/run-wp3-04-validation.mjs",
|
||||
"test:wp3-04:red": "node scripts/run-wp3-04-validation.mjs --phase red",
|
||||
"test:wp4-01": "node scripts/run-wp4-01-validation.mjs",
|
||||
"test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red",
|
||||
"test:wp4-02": "node scripts/run-wp4-02-validation.mjs",
|
||||
"test:wp4-02:red": "node scripts/run-wp4-02-validation.mjs --phase red",
|
||||
"test:wp4-03": "node scripts/run-wp4-03-validation.mjs",
|
||||
"test:wp4-03:red": "node scripts/run-wp4-03-validation.mjs --phase red",
|
||||
"test:wp4-04": "node scripts/run-wp4-04-validation.mjs",
|
||||
"test:wp4-04:red": "node scripts/run-wp4-04-validation.mjs --phase red",
|
||||
"test:wp4-05": "node scripts/run-wp4-05-validation.mjs",
|
||||
"test:wp4-05:red": "node scripts/run-wp4-05-validation.mjs --phase red",
|
||||
"test:wp4-06": "node scripts/run-wp4-06-validation.mjs",
|
||||
"test:wp4-06:red": "node scripts/run-wp4-06-validation.mjs --phase red",
|
||||
"test:wp5-01": "node scripts/run-wp5-01-validation.mjs",
|
||||
"test:wp5-01:red": "node scripts/run-wp5-01-validation.mjs --phase red",
|
||||
"test:wp5-02": "node scripts/run-wp5-02-validation.mjs",
|
||||
"test:wp5-02:red": "node scripts/run-wp5-02-validation.mjs --phase red",
|
||||
"preview:wp5-02": "node scripts/run-wp5-02-manual-preview.mjs",
|
||||
"test:wp5-03": "node scripts/run-wp5-03-validation.mjs",
|
||||
"test:wp5-03:red": "node scripts/run-wp5-03-validation.mjs --phase red",
|
||||
"test:wp7-05": "node scripts/run-wp7-05-validation.mjs",
|
||||
"test:wp7-05:unit": "node --test tests/package/wp7-05-ui-gate.test.mjs tests/package/wp7-05-coverage.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
@@ -47,6 +102,7 @@
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"axe-core": "4.12.1",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@dada/asset-compiler",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"compile": "node dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dada/static-sticker-catalog": "workspace:*",
|
||||
"@dada/template-registry": "workspace:*",
|
||||
"csv-parse": "7.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { compileAssetArchive, compileStaticStickerCatalog } from "./index.js";
|
||||
|
||||
function argument(name: string): string {
|
||||
const index = process.argv.indexOf(name);
|
||||
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
||||
if (!value) throw new Error(`missing ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedCount = process.argv.includes("--expected-count") ? Number(argument("--expected-count")) : undefined;
|
||||
const result = process.argv.includes("--catalog")
|
||||
? compileStaticStickerCatalog({
|
||||
...(expectedCount === undefined ? {} : { expectedCount }),
|
||||
outputDirectory: argument("--output"),
|
||||
releaseVersion: argument("--release-version"),
|
||||
sourceRoot: argument("--source-root"),
|
||||
})
|
||||
: compileAssetArchive({
|
||||
manifestPath: argument("--manifest"),
|
||||
outputDirectory: argument("--output"),
|
||||
releaseVersion: argument("--release-version"),
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : "asset compilation failed"}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, dirname, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
|
||||
import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/static-sticker-catalog";
|
||||
import { P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type CsvRow = Record<string, string>;
|
||||
|
||||
export interface AssetCompilerOptions {
|
||||
manifestPath: string;
|
||||
outputDirectory: string;
|
||||
releaseVersion: string;
|
||||
}
|
||||
|
||||
export interface AssetCompilerReport {
|
||||
schema_version: "asset-compiler-report/v1";
|
||||
release_version: string;
|
||||
input_manifest_sha256: string;
|
||||
source_files_read: number;
|
||||
source_mutations: number;
|
||||
executed_source_files: number;
|
||||
copied_source_files: number;
|
||||
derived_files: { created: number; reused: number; total: number };
|
||||
status: "passed";
|
||||
}
|
||||
|
||||
export interface AssetCompilerResult {
|
||||
manifest: JsonObject;
|
||||
report: AssetCompilerReport;
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalogCompilerOptions {
|
||||
sourceRoot: string;
|
||||
outputDirectory: string;
|
||||
releaseVersion: string;
|
||||
expectedCount?: number;
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalogCompilerReport {
|
||||
schema_version: "static-sticker-catalog-report/v1";
|
||||
release_version: string;
|
||||
source_files_read: number;
|
||||
source_mutations: number;
|
||||
copied_source_files: 0;
|
||||
status: "passed";
|
||||
count: number;
|
||||
duplicate_sha256_groups: number;
|
||||
}
|
||||
|
||||
interface SourceRoot {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface TrackedSource {
|
||||
bytes: number;
|
||||
mtime_ms: number;
|
||||
path: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
interface SourceEntry {
|
||||
canonical_id: string;
|
||||
collection_id: string;
|
||||
collection_root: string;
|
||||
item_directory: string;
|
||||
metadata: JsonObject;
|
||||
metadata_path: string;
|
||||
order: number;
|
||||
row: CsvRow;
|
||||
}
|
||||
|
||||
interface CollectionConfig {
|
||||
catalogPaths: Array<{ category: string; path: string }>;
|
||||
id: string;
|
||||
root: SourceRoot;
|
||||
}
|
||||
|
||||
const allowedCollections = new Set(["text_templates", "font_panel", "color_cards", "interactive_stickers"]);
|
||||
const releaseVersionPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
return `${JSON.stringify(stableValue(value), null, 2)}\n`;
|
||||
}
|
||||
|
||||
function sha256(value: Uint8Array | string): string {
|
||||
return createHash("sha256").update(value).digest("hex").toUpperCase();
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): JsonObject {
|
||||
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function inside(path: string, root: string): boolean {
|
||||
const candidate = resolve(path);
|
||||
const base = resolve(root);
|
||||
const rest = relative(base, candidate);
|
||||
return rest === "" || (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest));
|
||||
}
|
||||
|
||||
function realDirectory(path: string, label: string): string {
|
||||
if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} directory is unavailable`);
|
||||
return realpathSync(path);
|
||||
}
|
||||
|
||||
function resolveSourcePath(pathValue: string, base: string, root: string, label: string): string {
|
||||
const candidate = isAbsolute(pathValue) ? resolve(pathValue) : resolve(base, pathValue);
|
||||
if (!inside(candidate, root)) throw new Error(`${label} is outside the allowed source root`);
|
||||
if (!existsSync(candidate)) throw new Error(`${label} is unavailable`);
|
||||
const actual = realpathSync(candidate);
|
||||
if (!inside(actual, root)) throw new Error(`${label} resolves outside the allowed source root`);
|
||||
return actual;
|
||||
}
|
||||
|
||||
function relativeReference(pathValue: string, label: string): string {
|
||||
if (isAbsolute(pathValue)) throw new Error(`${label} must be relative`);
|
||||
const normalized = pathValue.replaceAll("\\", "/");
|
||||
if (normalized === "" || normalized === "." || normalized.split("/").includes("..")) throw new Error(`${label} has an unsafe relative path`);
|
||||
return normalized.replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function stringList(value: unknown, label: string): string[] {
|
||||
if (value === undefined || value === null || value === "") return [];
|
||||
if (Array.isArray(value)) return value.map((item, index) => requireString(item, `${label}[${index}]`));
|
||||
return [requireString(value, label)];
|
||||
}
|
||||
|
||||
function dynamicKeys(metadata: JsonObject, row: CsvRow): string[] {
|
||||
const value = metadata.dynamic_keys ?? row.dynamic_keys ?? "";
|
||||
if (value === "") return [];
|
||||
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && item.trim() !== "").map((item) => item.trim());
|
||||
return requireString(value, "dynamic_keys").split("|").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function numericOrder(row: CsvRow, fallback: number): number {
|
||||
const value = row.display_order ?? row.panel_order;
|
||||
const parsed = value ? Number(value) : Number.NaN;
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function collectFiles(metadata: JsonObject, keys: string[], label: string): string[] {
|
||||
const files = isRecord(metadata.files) ? metadata.files : {};
|
||||
return [...new Set(keys.flatMap((key) => stringList(files[key], `${label}.${key}`)).map((item) => relativeReference(item, `${label}.files`)))];
|
||||
}
|
||||
|
||||
function sourceRootLabel(collectionId: string, root: string, path: string): string {
|
||||
return `${collectionId}/${relative(root, path).replaceAll("\\", "/")}`;
|
||||
}
|
||||
|
||||
class SourceTracker {
|
||||
private readonly entries = new Map<string, TrackedSource>();
|
||||
|
||||
constructor(private readonly roots: SourceRoot[]) {}
|
||||
|
||||
read(path: string, label: string): Buffer {
|
||||
const root = this.roots.find((item) => inside(path, item.path));
|
||||
if (!root) throw new Error(`${label} is outside tracked sources`);
|
||||
const actual = realpathSync(path);
|
||||
if (!inside(actual, root.path)) throw new Error(`${label} resolves outside tracked sources`);
|
||||
const bytes = readFileSync(actual);
|
||||
const stats = statSync(actual);
|
||||
this.entries.set(actual, {
|
||||
bytes: stats.size,
|
||||
mtime_ms: stats.mtimeMs,
|
||||
path: sourceRootLabel(root.id, root.path, actual),
|
||||
sha256: sha256(bytes),
|
||||
});
|
||||
return bytes;
|
||||
}
|
||||
|
||||
before(): TrackedSource[] {
|
||||
return [...this.entries.values()].sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
after(): { entries: TrackedSource[]; mutations: number } {
|
||||
const entries = this.before().map((entry) => {
|
||||
const root = this.roots.find((item) => entry.path.startsWith(`${item.id}/`));
|
||||
if (!root) return entry;
|
||||
const actual = resolve(root.path, entry.path.slice(root.id.length + 1));
|
||||
const bytes = readFileSync(actual);
|
||||
const stats = statSync(actual);
|
||||
return { ...entry, bytes: stats.size, mtime_ms: stats.mtimeMs, sha256: sha256(bytes) };
|
||||
});
|
||||
const mutations = entries.reduce((count, entry, index) => {
|
||||
const before = this.before()[index];
|
||||
return count + (before?.sha256 !== entry.sha256 || before?.mtime_ms !== entry.mtime_ms || before?.bytes !== entry.bytes ? 1 : 0);
|
||||
}, 0);
|
||||
return { entries, mutations };
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(tracker: SourceTracker, path: string, label: string): JsonObject {
|
||||
try {
|
||||
return requireRecord(JSON.parse(tracker.read(path, label).toString("utf8")) as unknown, label);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error(`${label} is not valid JSON`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readCsv(tracker: SourceTracker, path: string, label: string): CsvRow[] {
|
||||
try {
|
||||
return parse(tracker.read(path, label).toString("utf8"), { bom: true, columns: true, skip_empty_lines: true, trim: true }) as CsvRow[];
|
||||
} catch {
|
||||
throw new Error(`${label} is not valid CSV`);
|
||||
}
|
||||
}
|
||||
|
||||
function itemDirectoryFor(collection: CollectionConfig, catalogPath: string, row: CsvRow): { directory: string; metadataPath: string } {
|
||||
if (collection.id === "font_panel") {
|
||||
const resourceDir = requireString(row.resource_dir, "font resource_dir");
|
||||
const directory = resolveSourcePath(resourceDir, collection.root.path, collection.root.path, "font resource_dir");
|
||||
return { directory, metadataPath: resolveSourcePath("metadata.json", directory, collection.root.path, "font metadata") };
|
||||
}
|
||||
const canonicalDir = relativeReference(requireString(row.canonical_dir, "canonical_dir"), "canonical_dir");
|
||||
const directory = resolveSourcePath(canonicalDir, dirname(catalogPath), collection.root.path, "canonical_dir");
|
||||
return { directory, metadataPath: resolveSourcePath("metadata.json", directory, collection.root.path, "metadata") };
|
||||
}
|
||||
|
||||
function collectionConfigs(manifest: JsonObject, manifestDirectory: string): { configs: CollectionConfig[]; roots: SourceRoot[] } {
|
||||
if (!Array.isArray(manifest.collections)) throw new Error("manifest collections must be an array");
|
||||
const configs: CollectionConfig[] = [];
|
||||
const roots: SourceRoot[] = [{ id: "handoff", path: realDirectory(manifestDirectory, "handoff") }];
|
||||
for (const raw of manifest.collections) {
|
||||
const collection = requireRecord(raw, "collection");
|
||||
const id = requireString(collection.id, "collection.id");
|
||||
if (!allowedCollections.has(id)) throw new Error(`unsupported collection ${id}`);
|
||||
const rootPath = resolve(manifestDirectory, requireString(collection.root, `${id}.root`));
|
||||
const root = { id, path: realDirectory(rootPath, `${id}.root`) };
|
||||
if (configs.some((item) => item.id === id)) throw new Error(`duplicate collection ${id}`);
|
||||
roots.push(root);
|
||||
const catalogPaths: Array<{ category: string; path: string }> = [];
|
||||
if (id === "text_templates") {
|
||||
const catalogs = requireRecord(collection.catalogs, `${id}.catalogs`);
|
||||
for (const [category, value] of Object.entries(catalogs).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const catalog = resolveSourcePath(requireString(value, `${id}.${category}`), root.path, root.path, `${id}.${category}`);
|
||||
catalogPaths.push({ category, path: catalog });
|
||||
}
|
||||
} else {
|
||||
const catalog = resolveSourcePath(requireString(collection.catalog, `${id}.catalog`), root.path, root.path, `${id}.catalog`);
|
||||
catalogPaths.push({ category: id, path: catalog });
|
||||
}
|
||||
configs.push({ catalogPaths, id, root });
|
||||
}
|
||||
if (configs.length !== allowedCollections.size) throw new Error("manifest collections are incomplete");
|
||||
return { configs, roots };
|
||||
}
|
||||
|
||||
function buildModel(entry: SourceEntry, metadata: JsonObject): JsonObject {
|
||||
const files = isRecord(metadata.files) ? metadata.files : {};
|
||||
const fonts = collectFiles(metadata, ["fonts"], entry.collection_id);
|
||||
const previews = collectFiles(metadata, ["preview"], entry.collection_id);
|
||||
const layerFiles = collectFiles(metadata, ["layer", "package", "renderer_source", "images"], entry.collection_id);
|
||||
const dynamicFiles = collectFiles(metadata, ["lua", "prefab"], entry.collection_id);
|
||||
const fields = dynamicKeys(metadata, entry.row);
|
||||
let nodes: JsonObject[];
|
||||
if (entry.collection_id === "text_templates") {
|
||||
nodes = [{ kind: "text", editable: true, font_references: fonts, slot: "content", text: metadata.default_text ?? entry.row.default_text ?? "" }, { kind: "asset_group", references: layerFiles }];
|
||||
} else if (entry.collection_id === "color_cards") {
|
||||
const runtime = isRecord(metadata.runtime) ? metadata.runtime : {};
|
||||
nodes = [{ kind: "color_card_renderer", palette_slots: 5, renderer_id: typeof runtime.stable_style_id === "string" ? runtime.stable_style_id : entry.row.display_name }];
|
||||
} else if (entry.collection_id === "interactive_stickers") {
|
||||
nodes = [{ conversion_inputs: dynamicFiles, fields, kind: "dynamic_provider", provider: "declarative" }, { kind: "asset_group", references: [...fonts, ...previews] }];
|
||||
} else {
|
||||
const runtime = isRecord(metadata.runtime) ? metadata.runtime : {};
|
||||
nodes = [{ family: entry.row.font_family ?? (typeof runtime.font_family === "string" ? runtime.font_family : ""), kind: "font", resource: entry.row.resource_dir ? "font_package" : "panel" }];
|
||||
}
|
||||
return {
|
||||
family: entry.collection_id,
|
||||
model_schema_version: "TemplateRenderModel/v1",
|
||||
nodes,
|
||||
template_id: entry.canonical_id,
|
||||
};
|
||||
}
|
||||
|
||||
function writeJson(path: string, value: unknown) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, stableJson(value));
|
||||
}
|
||||
|
||||
function staticStickerId(index: number) {
|
||||
return `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`;
|
||||
}
|
||||
|
||||
function pngDimensions(bytes: Buffer, label: string): { width: number; height: number } {
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (bytes.length < 45 || !bytes.subarray(0, 8).equals(signature)) throw new Error(`${label} is not a PNG`);
|
||||
let offset = 8;
|
||||
let sawHeader = false;
|
||||
let sawEnd = false;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
while (offset + 12 <= bytes.length) {
|
||||
const length = bytes.readUInt32BE(offset);
|
||||
const type = bytes.subarray(offset + 4, offset + 8).toString("ascii");
|
||||
const chunkEnd = offset + 12 + length;
|
||||
if (chunkEnd > bytes.length) throw new Error(`${label} has a truncated PNG chunk`);
|
||||
if (!sawHeader && type !== "IHDR") throw new Error(`${label} has no PNG header`);
|
||||
if (type === "IHDR") {
|
||||
if (sawHeader || length !== 13) throw new Error(`${label} has an invalid PNG header`);
|
||||
width = bytes.readUInt32BE(offset + 8);
|
||||
height = bytes.readUInt32BE(offset + 12);
|
||||
if (width < 1 || height < 1) throw new Error(`${label} has invalid PNG dimensions`);
|
||||
sawHeader = true;
|
||||
}
|
||||
if (type === "IEND") {
|
||||
if (length !== 0) throw new Error(`${label} has an invalid PNG end chunk`);
|
||||
sawEnd = true;
|
||||
break;
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
if (!sawHeader || !sawEnd) throw new Error(`${label} is not a complete PNG`);
|
||||
return { height, width };
|
||||
}
|
||||
|
||||
function staticStickerSourceSnapshot(sourceRoot: string, path: string, bytes: Buffer) {
|
||||
const stats = statSync(path);
|
||||
return {
|
||||
bytes: stats.size,
|
||||
mtime_ms: stats.mtimeMs,
|
||||
path: `sticker_source/${relative(sourceRoot, path).replaceAll("\\", "/")}`,
|
||||
sha256: sha256(bytes),
|
||||
};
|
||||
}
|
||||
|
||||
export function compileStaticStickerCatalog(options: StaticStickerCatalogCompilerOptions): { catalog: StaticStickerCatalog; report: StaticStickerCatalogCompilerReport } {
|
||||
if (!releaseVersionPattern.test(options.releaseVersion)) throw new Error("release version is unsafe");
|
||||
const sourceRoot = realDirectory(options.sourceRoot, "sticker source");
|
||||
const outputDirectory = resolve(options.outputDirectory);
|
||||
if (inside(outputDirectory, sourceRoot)) throw new Error("output directory is inside a source root");
|
||||
const items: StaticStickerCatalogItem[] = [];
|
||||
const sourceBefore: Array<{ bytes: number; mtime_ms: number; path: string; sha256: string }> = [];
|
||||
const partCounts: Record<string, number> = {};
|
||||
for (let part = 1; part <= 25; part += 1) {
|
||||
const partDirectory = resolve(sourceRoot, `sticker_part${part}`);
|
||||
if (!existsSync(partDirectory) || !statSync(partDirectory).isDirectory()) throw new Error(`sticker_part${part} directory is unavailable`);
|
||||
const actualPartDirectory = realpathSync(partDirectory);
|
||||
if (!inside(actualPartDirectory, sourceRoot)) throw new Error(`sticker_part${part} resolves outside the source root`);
|
||||
const files = readdirSync(actualPartDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.name.toLowerCase().endsWith(".png"))
|
||||
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
||||
partCounts[String(part)] = files.length;
|
||||
for (const [partOrder, entry] of files.entries()) {
|
||||
if (!entry.isFile()) throw new Error(`sticker_part${part}/${entry.name} is not a regular PNG file`);
|
||||
const path = resolve(actualPartDirectory, entry.name);
|
||||
const actualPath = realpathSync(path);
|
||||
if (!inside(actualPath, sourceRoot)) throw new Error(`sticker_part${part}/${entry.name} resolves outside the source root`);
|
||||
const bytes = readFileSync(actualPath);
|
||||
const dimensions = pngDimensions(bytes, `sticker_part${part}/${entry.name}`);
|
||||
const stableId = staticStickerId(items.length + 1);
|
||||
sourceBefore.push(staticStickerSourceSnapshot(sourceRoot, actualPath, bytes));
|
||||
items.push({
|
||||
enabled: true,
|
||||
height: dimensions.height,
|
||||
mime: "image/png",
|
||||
mime_type: "image/png",
|
||||
order: partOrder + 1,
|
||||
original_filename: entry.name,
|
||||
original_reference: `/api/v1/assets/public/${encodeURIComponent(options.releaseVersion)}/${encodeURIComponent(stableId)}`,
|
||||
origin: "bundled_read_only",
|
||||
part,
|
||||
relative_path: `sticker_part${part}/${entry.name}`,
|
||||
resource_version: options.releaseVersion,
|
||||
sha256: sha256(bytes),
|
||||
stable_id: stableId,
|
||||
thumbnail_reference: {
|
||||
media: "thumbnail",
|
||||
resource_id: stableId,
|
||||
resource_version: options.releaseVersion,
|
||||
url: `/api/v1/assets/public/${encodeURIComponent(options.releaseVersion)}/${encodeURIComponent(stableId)}?variant=thumbnail`,
|
||||
},
|
||||
width: dimensions.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
const duplicateSha256Groups = [...new Map(items.reduce((groups, item) => {
|
||||
const ids = groups.get(item.sha256) ?? [];
|
||||
ids.push(item.stable_id);
|
||||
groups.set(item.sha256, ids);
|
||||
return groups;
|
||||
}, new Map<string, string[]>())).entries()]
|
||||
.filter(([, stableIds]) => stableIds.length > 1)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([hash, stableIds]) => ({ sha256: hash, stable_ids: stableIds }));
|
||||
const payload = {
|
||||
count: items.length,
|
||||
duplicate_sha256_groups: duplicateSha256Groups,
|
||||
items,
|
||||
part_counts: partCounts,
|
||||
release_version: options.releaseVersion,
|
||||
schema_version: "StaticStickerCatalog/v1" as const,
|
||||
};
|
||||
const catalog: StaticStickerCatalog = { ...payload, manifest_sha256: sha256(stableJson(payload)) };
|
||||
const sourceAfter = sourceBefore.map((entry) => {
|
||||
const actualPath = resolve(sourceRoot, entry.path.slice("sticker_source/".length));
|
||||
const bytes = readFileSync(actualPath);
|
||||
return staticStickerSourceSnapshot(sourceRoot, actualPath, bytes);
|
||||
});
|
||||
const sourceMutations = sourceBefore.reduce((count, entry, index) => {
|
||||
const after = sourceAfter[index];
|
||||
return count + (after?.sha256 !== entry.sha256 || after?.mtime_ms !== entry.mtime_ms || after?.bytes !== entry.bytes ? 1 : 0);
|
||||
}, 0);
|
||||
if (options.expectedCount !== undefined && options.expectedCount !== items.length) throw new Error(`static sticker count mismatch: expected ${options.expectedCount}, received ${items.length}`);
|
||||
if (sourceMutations > 0) throw new Error("sticker source changed during catalog compilation");
|
||||
const duplicateGroups = duplicateSha256Groups.length;
|
||||
const report: StaticStickerCatalogCompilerReport = {
|
||||
copied_source_files: 0,
|
||||
count: items.length,
|
||||
duplicate_sha256_groups: duplicateGroups,
|
||||
release_version: options.releaseVersion,
|
||||
schema_version: "static-sticker-catalog-report/v1",
|
||||
source_files_read: sourceBefore.length,
|
||||
source_mutations: sourceMutations,
|
||||
status: "passed",
|
||||
};
|
||||
mkdirSync(outputDirectory, { recursive: true });
|
||||
writeJson(join(outputDirectory, "static-sticker-catalog.json"), catalog);
|
||||
writeJson(join(outputDirectory, "catalog-validation.json"), {
|
||||
count: items.length,
|
||||
duplicate_sha256_groups: duplicateGroups,
|
||||
expected_count: options.expectedCount ?? null,
|
||||
order_valid: items.every((item, index) => item.stable_id === staticStickerId(index + 1)),
|
||||
part_counts: partCounts,
|
||||
schema_version: "static-sticker-catalog-validation/v1",
|
||||
});
|
||||
writeJson(join(outputDirectory, "source-before.json"), { files: sourceBefore, schema_version: "source-snapshot/v1" });
|
||||
writeJson(join(outputDirectory, "source-after.json"), { files: sourceAfter, schema_version: "source-snapshot/v1" });
|
||||
writeJson(join(outputDirectory, "compiler-report.json"), report);
|
||||
writeJson(join(outputDirectory, "output-manifest.json"), {
|
||||
catalog_reference: "static-sticker-catalog.json",
|
||||
catalog_sha256: sha256(stableJson(catalog)),
|
||||
release_version: options.releaseVersion,
|
||||
schema_version: "asset-release-compiler/v1",
|
||||
});
|
||||
return { catalog, report };
|
||||
}
|
||||
|
||||
export function compileAssetArchive(options: AssetCompilerOptions): AssetCompilerResult {
|
||||
if (!releaseVersionPattern.test(options.releaseVersion)) throw new Error("release version is unsafe");
|
||||
const manifestPath = realpathSync(options.manifestPath);
|
||||
const manifestDirectory = realDirectory(dirname(manifestPath), "manifest");
|
||||
const roots = collectionConfigs(readJson(new SourceTracker([{ id: "handoff", path: manifestDirectory }]), manifestPath, "manifest"), manifestDirectory);
|
||||
const tracker = new SourceTracker(roots.roots);
|
||||
const manifest = readJson(tracker, manifestPath, "manifest");
|
||||
const handoff = requireString(manifest.web_handoff, "manifest.web_handoff");
|
||||
const validation = requireString(manifest.validation, "manifest.validation");
|
||||
tracker.read(resolveSourcePath(handoff, manifestDirectory, manifestDirectory, "web handoff"), "web handoff");
|
||||
const validationReport = readJson(tracker, resolveSourcePath(validation, manifestDirectory, manifestDirectory, "validation"), "validation");
|
||||
if (validationReport.all_valid !== true) throw new Error("source validation report is not all_valid");
|
||||
|
||||
const entries: SourceEntry[] = [];
|
||||
for (const config of roots.configs) {
|
||||
for (const catalog of config.catalogPaths) {
|
||||
for (const [index, row] of readCsv(tracker, catalog.path, `${config.id}.${catalog.category}`).entries()) {
|
||||
const identity = requireString(config.id === "font_panel" ? row.candidate_id : row.canonical_id, "canonical_id");
|
||||
const item = itemDirectoryFor(config, catalog.path, row);
|
||||
const metadata = readJson(tracker, item.metadataPath, `${identity}.metadata`);
|
||||
const metadataId = requireString(config.id === "font_panel" ? metadata.candidate_id : metadata.canonical_id, `${identity}.metadata.canonical_id`);
|
||||
if (metadataId !== identity) throw new Error(`${identity} metadata ID mismatch`);
|
||||
entries.push({ canonical_id: identity, collection_id: config.id, collection_root: config.root.path, item_directory: item.directory, metadata, metadata_path: item.metadataPath, order: numericOrder(row, index + 1), row });
|
||||
}
|
||||
}
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (ids.has(entry.canonical_id)) throw new Error(`duplicate canonical_id ${entry.canonical_id}`);
|
||||
ids.add(entry.canonical_id);
|
||||
}
|
||||
|
||||
const fontPanelIdBySha256 = new Map<string, string>();
|
||||
for (const entry of entries.filter((item) => item.collection_id === "font_panel")) {
|
||||
const value = entry.metadata.local_sha256;
|
||||
if (typeof value === "string" && /^[0-9a-f]{64}$/i.test(value)) fontPanelIdBySha256.set(value.toUpperCase(), entry.canonical_id);
|
||||
}
|
||||
const p0aTextIds = new Set<string>(P0A_TEXT_TEMPLATE_IDS);
|
||||
const fontRegistrations = new Map<string, { panelIds: string[]; sha256s: string[] }>();
|
||||
for (const entry of entries.filter((item) => item.collection_id === "text_templates" && p0aTextIds.has(item.canonical_id))) {
|
||||
const references = collectFiles(entry.metadata, ["fonts"], `${entry.canonical_id}.fonts`);
|
||||
const sha256s = references.map((reference) => {
|
||||
const path = resolveSourcePath(reference, entry.item_directory, entry.collection_root, `${entry.canonical_id} font`);
|
||||
return sha256(tracker.read(path, `${entry.canonical_id} font`));
|
||||
});
|
||||
const panelIds = [...new Set(sha256s.map((hash) => fontPanelIdBySha256.get(hash)).filter((id): id is string => id !== undefined))]
|
||||
.sort((left, right) => Number(left.slice(4)) - Number(right.slice(4)));
|
||||
fontRegistrations.set(entry.canonical_id, { panelIds, sha256s });
|
||||
}
|
||||
|
||||
const outputDirectory = resolve(options.outputDirectory);
|
||||
const outputRoots = [manifestDirectory, ...roots.roots.map((item) => item.path)];
|
||||
if (outputRoots.some((root) => inside(outputDirectory, root))) throw new Error("output directory is inside a source root");
|
||||
mkdirSync(outputDirectory, { recursive: true });
|
||||
const sourceBefore = tracker.before();
|
||||
const derivedRoot = join(outputDirectory, "derived-assets", options.releaseVersion);
|
||||
const derivedStats = { created: 0, reused: 0, total: 0 };
|
||||
const items = entries.map((entry) => {
|
||||
const metadataSha = sha256(stableJson(entry.metadata));
|
||||
const model = buildModel(entry, entry.metadata);
|
||||
const modelBytes = Buffer.from(stableJson(model));
|
||||
const modelHash = sha256(modelBytes);
|
||||
const derivedPath = join(derivedRoot, `${modelHash}.json`);
|
||||
const existing = existsSync(derivedPath);
|
||||
if (existing && !readFileSync(derivedPath).equals(modelBytes)) throw new Error("derived content hash collision");
|
||||
if (existing) derivedStats.reused += 1; else { mkdirSync(derivedRoot, { recursive: true }); writeFileSync(derivedPath, modelBytes); derivedStats.created += 1; }
|
||||
derivedStats.total += 1;
|
||||
const files = isRecord(entry.metadata.files) ? entry.metadata.files : {};
|
||||
const preview = stringList(files.preview, "preview")[0];
|
||||
const defaultFont = stringList(files.fonts, "fonts")[0];
|
||||
const runtime = isRecord(entry.metadata.runtime) ? entry.metadata.runtime : {};
|
||||
const canonicalPath = relative(entry.collection_root, entry.item_directory).replaceAll("\\", "/");
|
||||
const item: JsonObject = {
|
||||
canonical_id: entry.canonical_id,
|
||||
canonical_resource_reference: { collection: entry.collection_id, path: canonicalPath },
|
||||
category: entry.row.category || entry.collection_id,
|
||||
display_name: entry.metadata.display_name ?? entry.row.display_name ?? entry.row.canonical_id,
|
||||
display_order: entry.order,
|
||||
family: entry.metadata.family ?? entry.row.family ?? entry.collection_id,
|
||||
model_reference: { path: `derived-assets/${options.releaseVersion}/${modelHash}.json`, sha256: modelHash },
|
||||
release_status: "imported",
|
||||
release_tier: "full_p0",
|
||||
required_dynamic_fields: dynamicKeys(entry.metadata, entry.row),
|
||||
resource_class: entry.metadata.resource_class ?? entry.row.resource_class ?? "declarative",
|
||||
resource_version: metadataSha,
|
||||
test_batch_ids: [],
|
||||
validation_status: "metadata_validated",
|
||||
};
|
||||
if (entry.metadata.default_text !== undefined || entry.row.default_text !== undefined) item.default_text = entry.metadata.default_text ?? entry.row.default_text ?? "";
|
||||
if (defaultFont) item.default_font_reference = relativeReference(defaultFont, "default font");
|
||||
const fontRegistration = fontRegistrations.get(entry.canonical_id);
|
||||
if (fontRegistration) {
|
||||
item.font_panel_references = fontRegistration.panelIds;
|
||||
item.font_resource_sha256s = fontRegistration.sha256s;
|
||||
}
|
||||
if (entry.collection_id === "font_panel" && typeof entry.metadata.local_sha256 === "string") item.source_sha256 = entry.metadata.local_sha256.toUpperCase();
|
||||
if (preview) item.preview_reference = relativeReference(preview, "preview");
|
||||
if (typeof runtime.stable_style_id === "string") item.renderer_id = runtime.stable_style_id;
|
||||
return item;
|
||||
}).sort((left, right) => String(left.canonical_id).localeCompare(String(right.canonical_id)));
|
||||
const inputManifestSha = sha256(readFileSync(manifestPath));
|
||||
const after = tracker.after();
|
||||
const sourceMutations = after.mutations;
|
||||
const report: AssetCompilerReport = {
|
||||
copied_source_files: 0,
|
||||
derived_files: derivedStats,
|
||||
executed_source_files: 0,
|
||||
input_manifest_sha256: inputManifestSha,
|
||||
release_version: options.releaseVersion,
|
||||
schema_version: "asset-compiler-report/v1",
|
||||
source_files_read: sourceBefore.length,
|
||||
source_mutations: sourceMutations,
|
||||
status: "passed",
|
||||
};
|
||||
const releaseManifest: JsonObject = { compiler: "dada-asset-compiler", input_manifest_sha256: inputManifestSha, items, release_version: options.releaseVersion, schema_version: "asset-release-compiler/v1" };
|
||||
writeJson(join(outputDirectory, "source-before.json"), { schema_version: "source-snapshot/v1", files: sourceBefore });
|
||||
writeJson(join(outputDirectory, "source-after.json"), { files: after.entries, schema_version: "source-snapshot/v1" });
|
||||
writeJson(join(outputDirectory, "compiler-report.json"), report);
|
||||
writeJson(join(outputDirectory, "output-manifest.json"), releaseManifest);
|
||||
if (sourceMutations > 0) throw new Error("source changed during compilation");
|
||||
return { manifest: releaseManifest, report };
|
||||
}
|
||||
|
||||
export type { JsonObject };
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@dada/asset-renderer",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dada/template-registry": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { P0A_COLOR_CARD_IDS } from "@dada/template-registry";
|
||||
|
||||
export interface ColorCardDefinition {
|
||||
cardId: typeof P0A_COLOR_CARD_IDS[number];
|
||||
displayName: string;
|
||||
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
|
||||
rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket";
|
||||
styleId: "style_01" | "style_02" | "style_08" | "style_16";
|
||||
}
|
||||
|
||||
export type FiveColorPalette = readonly [string, string, string, string, string];
|
||||
|
||||
export interface ColorCardRenderPlan extends ColorCardDefinition {
|
||||
palette: FiveColorPalette;
|
||||
}
|
||||
|
||||
export const P0A_COLOR_CARD_DEFINITIONS: readonly ColorCardDefinition[] = [
|
||||
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
|
||||
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
|
||||
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
|
||||
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
|
||||
] as const;
|
||||
|
||||
function paletteSnapshot(palette: readonly string[]): FiveColorPalette {
|
||||
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) {
|
||||
throw new Error("color-card renderer requires exactly five colors");
|
||||
}
|
||||
return Object.freeze(palette.map((color) => color.toUpperCase())) as unknown as FiveColorPalette;
|
||||
}
|
||||
|
||||
export function createP0aColorCardRenderPlans(palette: readonly string[]): readonly ColorCardRenderPlan[] {
|
||||
const snapshot = paletteSnapshot(palette);
|
||||
return P0A_COLOR_CARD_DEFINITIONS.map((definition) => ({ ...definition, palette: snapshot }));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -14,6 +14,14 @@ export const stableEngineeringErrors = {
|
||||
ASSET_HISTORY_REFERENCE_CONFLICT: { httpStatus: 409, messageKey: "ASSET_HISTORY_REFERENCE_CONFLICT" },
|
||||
ASSET_CLEANUP_CANDIDATE_STALE: { httpStatus: 409, messageKey: "ASSET_CLEANUP_CANDIDATE_STALE" },
|
||||
STORAGE_CAPACITY_EXCEEDED: { httpStatus: 507, messageKey: "STORAGE_CAPACITY_EXCEEDED" },
|
||||
REGISTRATION_REJECTED: { httpStatus: 409, messageKey: "auth.registration.rejected" },
|
||||
REGISTRATION_REQUEST_INVALID: { httpStatus: 400, messageKey: "auth.registration.request_invalid" },
|
||||
IDEMPOTENCY_KEY_CONFLICT: { httpStatus: 409, messageKey: "request.idempotency_conflict" },
|
||||
AUTH_SESSION_INVALID: { httpStatus: 401, messageKey: "auth.session.invalid" },
|
||||
AUTH_SERVICE_UNAVAILABLE: { httpStatus: 503, messageKey: "auth.service.unavailable" },
|
||||
AUTH_ENTRY_REJECTED: { httpStatus: 409, messageKey: "auth.entry.rejected" },
|
||||
AUTH_RATE_LIMITED: { httpStatus: 429, messageKey: "auth.rate_limited" },
|
||||
AUTH_CSRF_INVALID: { httpStatus: 403, messageKey: "auth.csrf.invalid" },
|
||||
} as const;
|
||||
|
||||
export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors;
|
||||
@@ -45,6 +53,14 @@ export const StableEngineeringErrorCodeSchema = Type.Union(
|
||||
Type.Literal("ASSET_HISTORY_REFERENCE_CONFLICT"),
|
||||
Type.Literal("ASSET_CLEANUP_CANDIDATE_STALE"),
|
||||
Type.Literal("STORAGE_CAPACITY_EXCEEDED"),
|
||||
Type.Literal("REGISTRATION_REJECTED"),
|
||||
Type.Literal("REGISTRATION_REQUEST_INVALID"),
|
||||
Type.Literal("IDEMPOTENCY_KEY_CONFLICT"),
|
||||
Type.Literal("AUTH_SESSION_INVALID"),
|
||||
Type.Literal("AUTH_SERVICE_UNAVAILABLE"),
|
||||
Type.Literal("AUTH_ENTRY_REJECTED"),
|
||||
Type.Literal("AUTH_RATE_LIMITED"),
|
||||
Type.Literal("AUTH_CSRF_INVALID"),
|
||||
],
|
||||
{ $id: "StableEngineeringErrorCode" },
|
||||
);
|
||||
@@ -92,6 +108,7 @@ export const ErrorDetailsSchema = Type.Object(
|
||||
{ maxItems: 32 },
|
||||
),
|
||||
),
|
||||
conflict_model_ids: Type.Optional(Type.Array(Type.String({ maxLength: 160, minLength: 1 }), { maxItems: 3 })),
|
||||
latest_version: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.String({ maxLength: 120 })])),
|
||||
remaining_bytes: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
const stableResourcePattern = "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$";
|
||||
|
||||
export const RecentAssetKindSchema = Type.Union(
|
||||
[Type.Literal("static_sticker"), Type.Literal("text_template")],
|
||||
{ $id: "RecentAssetKind" },
|
||||
);
|
||||
|
||||
export const RecentAssetItemSchema = Type.Object(
|
||||
{
|
||||
asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||
asset_kind: Type.Ref(RecentAssetKindSchema),
|
||||
resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RecentAssetItem" },
|
||||
);
|
||||
|
||||
export const RecentAssetQuerySchema = Type.Object(
|
||||
{ asset_kind: Type.Ref(RecentAssetKindSchema) },
|
||||
{ additionalProperties: false, $id: "RecentAssetQuery" },
|
||||
);
|
||||
|
||||
export const RecentAssetListResponseSchema = Type.Object(
|
||||
{ items: Type.Array(Type.Ref(RecentAssetItemSchema), { maxItems: 12 }) },
|
||||
{ additionalProperties: false, $id: "RecentAssetListResponse" },
|
||||
);
|
||||
|
||||
export const RecentAssetRecordRequestSchema = Type.Object(
|
||||
{
|
||||
asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||
asset_kind: Type.Ref(RecentAssetKindSchema),
|
||||
resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RecentAssetRecordRequest" },
|
||||
);
|
||||
|
||||
export const RecentAssetRecordResponseSchema = Type.Object(
|
||||
{ status: Type.Literal("recorded") },
|
||||
{ additionalProperties: false, $id: "RecentAssetRecordResponse" },
|
||||
);
|
||||
|
||||
export type RecentAssetQuery = Static<typeof RecentAssetQuerySchema>;
|
||||
export type RecentAssetRecordRequest = Static<typeof RecentAssetRecordRequestSchema>;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
|
||||
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
|
||||
const emailPattern = "^[^@\\s]{1,128}@[^@\\s]{1,190}$";
|
||||
|
||||
export const RegistrationSendRequestSchema = Type.Object(
|
||||
{
|
||||
email: Type.String({ maxLength: 320, pattern: emailPattern }),
|
||||
invite_code: Type.String({ maxLength: 160, minLength: 8 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationSendRequest" },
|
||||
);
|
||||
|
||||
export const RegistrationSendResponseSchema = Type.Object(
|
||||
{
|
||||
challenge_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
registration_id: Type.String({ pattern: uuidPattern }),
|
||||
resend_available_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("verification_sent"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationSendResponse" },
|
||||
);
|
||||
|
||||
export const RegistrationCompleteRequestSchema = Type.Object(
|
||||
{
|
||||
creator_name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
privacy_consent_accepted: Type.Boolean(),
|
||||
privacy_notice_version: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
registration_id: Type.String({ pattern: uuidPattern }),
|
||||
social_id: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
verification_code: Type.String({ pattern: "^[0-9]{6}$" }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationCompleteRequest" },
|
||||
);
|
||||
|
||||
export const RegistrationCompleteHeadersSchema = Type.Object(
|
||||
{
|
||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
},
|
||||
{ additionalProperties: true, $id: "RegistrationCompleteHeaders" },
|
||||
);
|
||||
|
||||
export const AuthenticatedUserSchema = Type.Object(
|
||||
{
|
||||
creator_name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
role: Type.Literal("user"),
|
||||
social_id: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
status: Type.Literal("active"),
|
||||
user_id: Type.String({ pattern: uuidPattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AuthenticatedUser" },
|
||||
);
|
||||
|
||||
export const CreditSummarySchema = Type.Object(
|
||||
{
|
||||
available_balance: Type.Integer(),
|
||||
reserved_balance: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "CreditSummary" },
|
||||
);
|
||||
|
||||
export const RegistrationCompleteResponseSchema = Type.Object(
|
||||
{
|
||||
credits: Type.Ref(CreditSummarySchema),
|
||||
session_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("registered"),
|
||||
user: Type.Ref(AuthenticatedUserSchema),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationCompleteResponse" },
|
||||
);
|
||||
|
||||
export const UserSessionResponseSchema = Type.Object(
|
||||
{
|
||||
audience: Type.Literal("user"),
|
||||
authenticated: Type.Literal(true),
|
||||
credits: Type.Ref(CreditSummarySchema),
|
||||
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
user: Type.Ref(AuthenticatedUserSchema),
|
||||
},
|
||||
{ additionalProperties: false, $id: "UserSessionResponse" },
|
||||
);
|
||||
|
||||
export const LoginSendRequestSchema = Type.Object(
|
||||
{
|
||||
email: Type.String({ maxLength: 320, pattern: emailPattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LoginSendRequest" },
|
||||
);
|
||||
|
||||
export const LoginCompleteRequestSchema = Type.Object(
|
||||
{
|
||||
registration_id: Type.String({ pattern: uuidPattern }),
|
||||
verification_code: Type.String({ pattern: "^[0-9]{6}$" }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LoginCompleteRequest" },
|
||||
);
|
||||
|
||||
export const LoginCompleteResponseSchema = Type.Object(
|
||||
{
|
||||
audience: Type.Literal("user"),
|
||||
credits: Type.Ref(CreditSummarySchema),
|
||||
session_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("authenticated"),
|
||||
user: Type.Ref(AuthenticatedUserSchema),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LoginCompleteResponse" },
|
||||
);
|
||||
|
||||
export const AdminLoginSendRequestSchema = Type.Object(
|
||||
{
|
||||
email: Type.String({ maxLength: 320, pattern: emailPattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AdminLoginSendRequest" },
|
||||
);
|
||||
|
||||
export const AdminAuthenticatedUserSchema = Type.Object(
|
||||
{
|
||||
role: Type.Literal("super_admin"),
|
||||
status: Type.Literal("active"),
|
||||
user_id: Type.String({ pattern: uuidPattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AdminAuthenticatedUser" },
|
||||
);
|
||||
|
||||
export const AdminLoginCompleteRequestSchema = Type.Object(
|
||||
{
|
||||
registration_id: Type.String({ pattern: uuidPattern }),
|
||||
verification_code: Type.String({ pattern: "^[0-9]{6}$" }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AdminLoginCompleteRequest" },
|
||||
);
|
||||
|
||||
export const AdminLoginCompleteResponseSchema = Type.Object(
|
||||
{
|
||||
admin: Type.Ref(AdminAuthenticatedUserSchema),
|
||||
audience: Type.Literal("admin"),
|
||||
session_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("authenticated"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AdminLoginCompleteResponse" },
|
||||
);
|
||||
|
||||
export const AdminSessionResponseSchema = Type.Object(
|
||||
{
|
||||
acknowledged_private_content_notice_version: Type.Union([Type.String(), Type.Null()]),
|
||||
admin: Type.Ref(AdminAuthenticatedUserSchema),
|
||||
audience: Type.Literal("admin"),
|
||||
authenticated: Type.Literal(true),
|
||||
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]),
|
||||
expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
notice_acknowledged: Type.Boolean(),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AdminSessionResponse" },
|
||||
);
|
||||
|
||||
export const LogoutHeadersSchema = Type.Object(
|
||||
{
|
||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
"x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
},
|
||||
{ additionalProperties: true, $id: "LogoutHeaders" },
|
||||
);
|
||||
|
||||
export const LogoutResponseSchema = Type.Object(
|
||||
{ status: Type.Literal("logged_out") },
|
||||
{ additionalProperties: false, $id: "LogoutResponse" },
|
||||
);
|
||||
|
||||
export const CsrfHeadersSchema = Type.Object(
|
||||
{
|
||||
"x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
},
|
||||
{ additionalProperties: true, $id: "CsrfHeaders" },
|
||||
);
|
||||
|
||||
export const AccountSettingsResponseSchema = Type.Object(
|
||||
{
|
||||
account: Type.Object(
|
||||
{
|
||||
email: Type.String({ maxLength: 320, pattern: emailPattern }),
|
||||
status: Type.Literal("active"),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
local_data: Type.Object(
|
||||
{
|
||||
backup_enabled: Type.Literal(false),
|
||||
capacity_status: Type.Union([
|
||||
Type.Literal("normal"), Type.Literal("warning"), Type.Literal("critical"),
|
||||
Type.Literal("full"), Type.Literal("unavailable"),
|
||||
]),
|
||||
hard_limit_bytes: Type.Integer({ minimum: 1 }),
|
||||
location: Type.Literal("configured_local_data_root"),
|
||||
managed_content_bytes: Type.Integer({ minimum: 0 }),
|
||||
migration_supported: Type.Literal(false),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
profile: Type.Object(
|
||||
{
|
||||
creator_name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
social_id: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AccountSettingsResponse" },
|
||||
);
|
||||
|
||||
export const AccountProfileUpdateRequestSchema = Type.Object(
|
||||
{
|
||||
creator_name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
social_id: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AccountProfileUpdateRequest" },
|
||||
);
|
||||
|
||||
export const AccountProfileUpdateResponseSchema = Type.Object(
|
||||
{
|
||||
profile: AccountSettingsResponseSchema.properties.profile,
|
||||
status: Type.Literal("saved"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AccountProfileUpdateResponse" },
|
||||
);
|
||||
|
||||
export const AccountDeletionSendResponseSchema = Type.Object(
|
||||
{
|
||||
challenge_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
deletion_id: Type.String({ pattern: uuidPattern }),
|
||||
resend_available_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("verification_sent"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AccountDeletionSendResponse" },
|
||||
);
|
||||
|
||||
export const AccountDeletionCompleteRequestSchema = Type.Object(
|
||||
{
|
||||
confirmation: Type.Literal("注销账号"),
|
||||
deletion_id: Type.String({ pattern: uuidPattern }),
|
||||
verification_code: Type.String({ pattern: "^[0-9]{6}$" }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AccountDeletionCompleteRequest" },
|
||||
);
|
||||
|
||||
export const AccountDeletionResponseSchema = Type.Object(
|
||||
{ status: Type.Literal("deleted") },
|
||||
{ additionalProperties: false, $id: "AccountDeletionResponse" },
|
||||
);
|
||||
|
||||
export type RegistrationSendRequest = Static<typeof RegistrationSendRequestSchema>;
|
||||
export type RegistrationSendResponse = Static<typeof RegistrationSendResponseSchema>;
|
||||
export type RegistrationCompleteRequest = Static<typeof RegistrationCompleteRequestSchema>;
|
||||
export type RegistrationCompleteResponse = Static<typeof RegistrationCompleteResponseSchema>;
|
||||
export type UserSessionResponse = Static<typeof UserSessionResponseSchema>;
|
||||
export type LoginSendRequest = Static<typeof LoginSendRequestSchema>;
|
||||
export type LoginCompleteRequest = Static<typeof LoginCompleteRequestSchema>;
|
||||
export type AdminLoginSendRequest = Static<typeof AdminLoginSendRequestSchema>;
|
||||
export type AdminLoginCompleteRequest = Static<typeof AdminLoginCompleteRequestSchema>;
|
||||
export type AccountProfileUpdateRequest = Static<typeof AccountProfileUpdateRequestSchema>;
|
||||
export type AccountDeletionCompleteRequest = Static<typeof AccountDeletionCompleteRequestSchema>;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
|
||||
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
|
||||
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
|
||||
const stableResourcePattern = "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$";
|
||||
|
||||
export const CanvasBackgroundAdjustmentsSchema = Type.Object(
|
||||
{
|
||||
brightness: Type.Number({ maximum: 100, minimum: -100 }),
|
||||
contrast: Type.Number({ maximum: 100, minimum: -100 }),
|
||||
crop: Type.Union([
|
||||
Type.Object({
|
||||
height: Type.Number({ maximum: 1, minimum: 0 }),
|
||||
width: Type.Number({ maximum: 1, minimum: 0 }),
|
||||
x: Type.Number({ maximum: 1, minimum: 0 }),
|
||||
y: Type.Number({ maximum: 1, minimum: 0 }),
|
||||
}, { additionalProperties: false }),
|
||||
Type.Null(),
|
||||
]),
|
||||
filter: Type.String({ maxLength: 64, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
fit: Type.Union([Type.Literal("fill"), Type.Literal("fit"), Type.Literal("crop")]),
|
||||
saturation: Type.Number({ maximum: 100, minimum: -100 }),
|
||||
sharpness: Type.Number({ maximum: 100, minimum: 0 }),
|
||||
temperature: Type.Number({ maximum: 100, minimum: -100 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "CanvasBackgroundAdjustments" },
|
||||
);
|
||||
|
||||
const CanvasScalarSchema = Type.Union([
|
||||
Type.String({ maxLength: 4_000 }), Type.Number(), Type.Boolean(), Type.Null(),
|
||||
]);
|
||||
|
||||
export const CanvasElementSchema = Type.Object(
|
||||
{
|
||||
colors: Type.Optional(Type.Array(Type.String({ pattern: "^#[0-9A-Fa-f]{6}$" }), { maxItems: 5, minItems: 5 })),
|
||||
content: Type.Optional(Type.String({ maxLength: 4_000 })),
|
||||
coordinates: Type.Optional(Type.Object({
|
||||
latitude: Type.Number({ maximum: 90, minimum: -90 }),
|
||||
longitude: Type.Number({ maximum: 180, minimum: -180 }),
|
||||
}, { additionalProperties: false })),
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
dynamic_fields: Type.Optional(Type.Record(Type.String({ pattern: "^[A-Za-z][A-Za-z0-9_]{0,63}$" }), CanvasScalarSchema)),
|
||||
element_id: Type.String({ pattern: uuidPattern }),
|
||||
font_override: Type.Optional(Type.String({ maxLength: 120, pattern: stableResourcePattern })),
|
||||
font_size: Type.Optional(Type.Number({ maximum: 512, minimum: 1 })),
|
||||
formatted_value: Type.Optional(Type.String({ maxLength: 4_000 })),
|
||||
opacity: Type.Number({ maximum: 1, minimum: 0 }),
|
||||
position: Type.Object({ x: Type.Number(), y: Type.Number() }, { additionalProperties: false }),
|
||||
resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||
rotation: Type.Number({ maximum: 360, minimum: -360 }),
|
||||
scale: Type.Object({
|
||||
x: Type.Number({ maximum: 100, minimum: 0.01 }),
|
||||
y: Type.Number({ maximum: 100, minimum: 0.01 }),
|
||||
}, { additionalProperties: false }),
|
||||
style_id: Type.Optional(Type.String({ maxLength: 120, pattern: stableResourcePattern })),
|
||||
style_parameters: Type.Optional(Type.Record(Type.String({ pattern: "^[A-Za-z][A-Za-z0-9_]{0,63}$" }), CanvasScalarSchema)),
|
||||
template_or_asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||
type: Type.Union([
|
||||
Type.Literal("text_template"), Type.Literal("static_sticker"),
|
||||
Type.Literal("color_card"), Type.Literal("dynamic_sticker"),
|
||||
]),
|
||||
z_index: Type.Integer({ maximum: 49, minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "CanvasElement" },
|
||||
);
|
||||
|
||||
export const CanvasStateSchema = Type.Object(
|
||||
{
|
||||
background: Type.Object({
|
||||
adjustments: Type.Ref(CanvasBackgroundAdjustmentsSchema),
|
||||
asset_id: Type.Union([Type.String({ pattern: uuidPattern }), Type.Null()]),
|
||||
}, { additionalProperties: false }),
|
||||
elements: Type.Array(Type.Ref(CanvasElementSchema), { maxItems: 50 }),
|
||||
pixel_height: Type.Integer({ minimum: 1 }),
|
||||
pixel_width: Type.Integer({ minimum: 1 }),
|
||||
ratio: Type.Union([Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")]),
|
||||
schema_version: Type.Literal(1),
|
||||
},
|
||||
{ additionalProperties: false, $id: "CanvasState" },
|
||||
);
|
||||
|
||||
export const ProjectEditableStateSchema = Type.Object(
|
||||
{
|
||||
canvas_state: Type.Ref(CanvasStateSchema),
|
||||
name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectEditableState" },
|
||||
);
|
||||
|
||||
export const ProjectStateSaveHeadersSchema = Type.Object(
|
||||
{
|
||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
"if-match": Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
"x-csrf-token": Type.String({ maxLength: 512, minLength: 32 }),
|
||||
},
|
||||
{ additionalProperties: true, $id: "ProjectStateSaveHeaders" },
|
||||
);
|
||||
|
||||
export const ProjectStateSaveResponseSchema = Type.Object(
|
||||
{ save_status: Type.Literal("saved"), state_version: Type.Integer({ minimum: 2 }) },
|
||||
{ additionalProperties: false, $id: "ProjectStateSaveResponse" },
|
||||
);
|
||||
|
||||
export const ProjectStateConflictResponseSchema = Type.Object(
|
||||
{ latest_state_version: Type.Integer({ minimum: 1 }), save_status: Type.Literal("conflicted") },
|
||||
{ additionalProperties: false, $id: "ProjectStateConflictResponse" },
|
||||
);
|
||||
|
||||
export type CanvasState = Static<typeof CanvasStateSchema>;
|
||||
export type ProjectEditableState = Static<typeof ProjectEditableStateSchema>;
|
||||
export type ProjectStateSaveHeaders = Static<typeof ProjectStateSaveHeadersSchema>;
|
||||
|
||||
export function isCanvasState(value: unknown): value is CanvasState {
|
||||
if (!Value.Check(CanvasStateSchema, [CanvasBackgroundAdjustmentsSchema, CanvasElementSchema], value)) return false;
|
||||
const state = value as CanvasState;
|
||||
const ids = new Set(state.elements.map((element) => element.element_id));
|
||||
const zIndexes = new Set(state.elements.map((element) => element.z_index));
|
||||
return ids.size === state.elements.length && zIndexes.size === state.elements.length;
|
||||
}
|
||||
|
||||
export function isProjectEditableState(value: unknown): value is ProjectEditableState {
|
||||
return Value.Check(
|
||||
ProjectEditableStateSchema,
|
||||
[CanvasBackgroundAdjustmentsSchema, CanvasElementSchema, CanvasStateSchema],
|
||||
value,
|
||||
)
|
||||
&& isCanvasState((value as ProjectEditableState).canvas_state);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
import { CreditSummarySchema } from "./auth.js";
|
||||
|
||||
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
|
||||
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
|
||||
|
||||
export const CreditEntryTypeSchema = Type.Union([
|
||||
Type.Literal("registration_grant"), Type.Literal("generation_reserve"), Type.Literal("generation_commit"),
|
||||
Type.Literal("generation_release"), Type.Literal("admin_adjustment"),
|
||||
], { $id: "CreditEntryType" });
|
||||
export const CreditEntryStatusSchema = Type.Union([
|
||||
Type.Literal("succeeded"), Type.Literal("frozen"), Type.Literal("committed"), Type.Literal("released"),
|
||||
], { $id: "CreditEntryStatus" });
|
||||
export const CreditReferenceTypeSchema = Type.Union([
|
||||
Type.Literal("registration"), Type.Literal("generation"), Type.Literal("admin_adjustment"),
|
||||
], { $id: "CreditReferenceType" });
|
||||
export const CreditBalanceResponseSchema = Type.Object({
|
||||
...CreditSummarySchema.properties,
|
||||
updated_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
}, { additionalProperties: false, $id: "CreditBalanceResponse" });
|
||||
export const CreditLedgerEntrySchema = Type.Object({
|
||||
amount: Type.Integer(),
|
||||
available_after: Type.Integer(),
|
||||
available_before: Type.Integer(),
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
entry_id: Type.String({ pattern: uuidPattern }),
|
||||
entry_type: Type.Ref(CreditEntryTypeSchema),
|
||||
model_id: Type.Union([Type.String({ maxLength: 160, minLength: 1 }), Type.Null()]),
|
||||
reason: Type.Union([Type.String({ maxLength: 500, minLength: 1 }), Type.Null()]),
|
||||
reference_id: Type.Union([Type.String({ pattern: uuidPattern }), Type.Null()]),
|
||||
reference_type: Type.Union([Type.Ref(CreditReferenceTypeSchema), Type.Null()]),
|
||||
reserved_after: Type.Integer({ minimum: 0 }),
|
||||
reserved_before: Type.Integer({ minimum: 0 }),
|
||||
status: Type.Ref(CreditEntryStatusSchema),
|
||||
}, { additionalProperties: false, $id: "CreditLedgerEntry" });
|
||||
export const CreditLedgerQuerySchema = Type.Object({
|
||||
cursor: Type.Optional(Type.String({ maxLength: 300, minLength: 1, pattern: "^[A-Za-z0-9_-]+$" })),
|
||||
event_type: Type.Optional(Type.Ref(CreditEntryTypeSchema)),
|
||||
from: Type.Optional(Type.String({ pattern: isoTimestampPattern })),
|
||||
limit: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })),
|
||||
to: Type.Optional(Type.String({ pattern: isoTimestampPattern })),
|
||||
}, { additionalProperties: false, $id: "CreditLedgerQuery" });
|
||||
export const CreditLedgerResponseSchema = Type.Object({
|
||||
credits: Type.Ref(CreditSummarySchema),
|
||||
entries: Type.Array(Type.Ref(CreditLedgerEntrySchema), { maxItems: 100 }),
|
||||
next_cursor: Type.Union([Type.String({ maxLength: 300, minLength: 1 }), Type.Null()]),
|
||||
updated_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
}, { additionalProperties: false, $id: "CreditLedgerResponse" });
|
||||
export const AdminCreditParamsSchema = Type.Object({
|
||||
userId: Type.String({ pattern: uuidPattern }),
|
||||
}, { additionalProperties: false, $id: "AdminCreditParams" });
|
||||
export const CreditAdjustmentHeadersSchema = Type.Object({
|
||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
"x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
}, { additionalProperties: true, $id: "CreditAdjustmentHeaders" });
|
||||
export const CreditAdjustmentRequestSchema = Type.Object({
|
||||
adjustment_id: Type.String({ pattern: uuidPattern }),
|
||||
amount: Type.Union([Type.Integer({ maximum: -1 }), Type.Integer({ minimum: 1 })]),
|
||||
reason: Type.String({ maxLength: 500, minLength: 1 }),
|
||||
}, { additionalProperties: false, $id: "CreditAdjustmentRequest" });
|
||||
export const CreditAdjustmentResponseSchema = Type.Object({
|
||||
adjustment_id: Type.String({ pattern: uuidPattern }),
|
||||
available_balance: Type.Integer(),
|
||||
reserved_balance: Type.Integer({ minimum: 0 }),
|
||||
status: Type.Literal("adjusted"),
|
||||
}, { additionalProperties: false, $id: "CreditAdjustmentResponse" });
|
||||
|
||||
export type CreditLedgerQuery = Static<typeof CreditLedgerQuerySchema>;
|
||||
export type AdminCreditParams = Static<typeof AdminCreditParamsSchema>;
|
||||
export type CreditAdjustmentHeaders = Static<typeof CreditAdjustmentHeadersSchema>;
|
||||
export type CreditAdjustmentRequest = Static<typeof CreditAdjustmentRequestSchema>;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
import { GenerationErrorCategorySchema } from "./api.js";
|
||||
import { ProjectIdSchema, ProjectRatioSchema } from "./projects.js";
|
||||
|
||||
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
|
||||
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
|
||||
|
||||
export const GenerationTaskStatusSchema = Type.Union([
|
||||
Type.Literal("queued"),
|
||||
Type.Literal("running"),
|
||||
Type.Literal("succeeded"),
|
||||
Type.Literal("failed"),
|
||||
Type.Literal("rejected"),
|
||||
], { $id: "GenerationTaskStatus" });
|
||||
|
||||
export const GenerationTaskResponseSchema = Type.Object({
|
||||
confirmed_credit_cost: Type.Integer({ minimum: 1 }),
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
error_category: Type.Union([Type.Ref(GenerationErrorCategorySchema), Type.Null()]),
|
||||
generation_id: Type.String({ pattern: uuidPattern }),
|
||||
model_config_version: Type.Integer({ minimum: 1 }),
|
||||
model_id: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
project_id: Type.Ref(ProjectIdSchema),
|
||||
prompt: Type.String({ maxLength: 4_000, minLength: 1 }),
|
||||
ratio: Type.Ref(ProjectRatioSchema),
|
||||
reference_asset_ids: Type.Array(Type.String({ pattern: uuidPattern }), { maxItems: 16 }),
|
||||
reference_count: Type.Integer({ minimum: 0 }),
|
||||
reserved_credits: Type.Integer({ minimum: 0 }),
|
||||
status: Type.Ref(GenerationTaskStatusSchema),
|
||||
updated_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
}, { additionalProperties: false, $id: "GenerationTaskResponse" });
|
||||
|
||||
export const GenerationCreateResponseSchema = Type.Object({
|
||||
created: Type.Boolean(),
|
||||
task: Type.Ref(GenerationTaskResponseSchema),
|
||||
}, { additionalProperties: false, $id: "GenerationCreateResponse" });
|
||||
|
||||
export const GenerationParamsSchema = Type.Object({
|
||||
generationId: Type.String({ pattern: uuidPattern }),
|
||||
}, { additionalProperties: false, $id: "GenerationParams" });
|
||||
|
||||
export const GenerationCreateHeadersSchema = Type.Object({
|
||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
"x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
}, { additionalProperties: true, $id: "GenerationCreateHeaders" });
|
||||
|
||||
export const GenerationMultipartBodySchema = Type.Object({
|
||||
client_submission_id: Type.String({ pattern: uuidPattern }),
|
||||
confirmed_credit_cost: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
creation_mode: Type.Union([Type.Literal("new_project"), Type.Literal("existing_project")]),
|
||||
existing_reference_asset_ids: Type.Optional(Type.String({ maxLength: 12_000 })),
|
||||
model_config_version: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
model_id: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
project_id: Type.Optional(Type.String({ pattern: uuidPattern })),
|
||||
prompt: Type.String({ maxLength: 4_000, minLength: 1 }),
|
||||
ratio: Type.Ref(ProjectRatioSchema),
|
||||
reference_files: Type.Optional(Type.Array(Type.String({ format: "binary" }), { maxItems: 16 })),
|
||||
reference_manifest: Type.Optional(Type.String({ maxLength: 16_384 })),
|
||||
}, { additionalProperties: false, $id: "GenerationMultipartBody" });
|
||||
|
||||
export type GenerationCreateHeaders = Static<typeof GenerationCreateHeadersSchema>;
|
||||
export type GenerationParams = Static<typeof GenerationParamsSchema>;
|
||||
export type GenerationTaskResponse = Static<typeof GenerationTaskResponseSchema>;
|
||||
@@ -1,4 +1,13 @@
|
||||
export { Type } from "@sinclair/typebox";
|
||||
export * from "./api.js";
|
||||
export * from "./assets.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./canvas.js";
|
||||
export * from "./credits.js";
|
||||
export * from "./events.js";
|
||||
export * from "./generations.js";
|
||||
export * from "./location.js";
|
||||
export * from "./projects.js";
|
||||
export * from "./registration-notice.js";
|
||||
export * from "./models.js";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
export const ReverseGeocodeRequestSchema = Type.Object({
|
||||
latitude: Type.Number({ maximum: 90, minimum: -90 }),
|
||||
longitude: Type.Number({ maximum: 180, minimum: -180 }),
|
||||
}, { additionalProperties: false, $id: "ReverseGeocodeRequest" });
|
||||
|
||||
export const ReverseGeocodeResponseSchema = Type.Object({
|
||||
formatted_value: Type.String({ maxLength: 200, minLength: 1 }),
|
||||
service_mode: Type.Literal("mock"),
|
||||
status: Type.Literal("resolved"),
|
||||
}, { additionalProperties: false, $id: "ReverseGeocodeResponse" });
|
||||
|
||||
export type ReverseGeocodeRequest = Static<typeof ReverseGeocodeRequestSchema>;
|
||||
export type ReverseGeocodeResponse = Static<typeof ReverseGeocodeResponseSchema>;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
|
||||
|
||||
export const ModelIdSchema = Type.String({ maxLength: 80, minLength: 1, pattern: "^[a-z0-9][a-z0-9.-]+$", $id: "ModelId" });
|
||||
export const ModelContractValidationStatusSchema = Type.Union([
|
||||
Type.Literal("blocked"), Type.Literal("unverified"), Type.Literal("verified"),
|
||||
], { $id: "ModelContractValidationStatus" });
|
||||
export const ModelRuntimeReasonSchema = Type.Union([
|
||||
Type.Literal("available"), Type.Literal("configured_disabled"), Type.Literal("contract_unverified"),
|
||||
Type.Literal("contract_blocked"), Type.Literal("gateway_balance_insufficient"), Type.Literal("gateway_paused"),
|
||||
Type.Literal("worker_degraded"),
|
||||
], { $id: "ModelRuntimeReason" });
|
||||
export const ModelReferenceLimitsSchema = Type.Object({
|
||||
max_file_bytes: Type.Integer({ minimum: 1 }),
|
||||
max_files: Type.Integer({ minimum: 0 }),
|
||||
max_total_bytes: Type.Integer({ minimum: 1 }),
|
||||
}, { additionalProperties: false, $id: "ModelReferenceLimits" });
|
||||
export const ModelRuntimeAvailabilitySchema = Type.Object({
|
||||
available_for_new_jobs: Type.Boolean(),
|
||||
checked_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
reason: Type.Ref(ModelRuntimeReasonSchema),
|
||||
}, { additionalProperties: false, $id: "ModelRuntimeAvailability" });
|
||||
export const ModelConfigSchema = Type.Object({
|
||||
config_version: Type.Integer({ minimum: 1 }),
|
||||
contract_evidence_ref: Type.Union([Type.String({ maxLength: 160, minLength: 1 }), Type.Null()]),
|
||||
contract_validation_status: Type.Ref(ModelContractValidationStatusSchema),
|
||||
credit_cost: Type.Integer({ minimum: 1 }),
|
||||
display_name: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
enabled: Type.Boolean(),
|
||||
error_mapping_profile: Type.Record(Type.String(), Type.String()),
|
||||
gateway_account_ref: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
is_default: Type.Boolean(),
|
||||
model_id: Type.Ref(ModelIdSchema),
|
||||
prompt_max_length: Type.Integer({ minimum: 1 }),
|
||||
recommendation_priority: Type.Integer({ minimum: 1 }),
|
||||
reference_limits: Type.Ref(ModelReferenceLimitsSchema),
|
||||
route_profile: Type.Record(Type.String(), Type.Unknown()),
|
||||
runtime_availability: Type.Ref(ModelRuntimeAvailabilitySchema),
|
||||
safety_source: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
supported_ratios: Type.Array(Type.String({ maxLength: 20, minLength: 1 }), { minItems: 1, maxItems: 8 }),
|
||||
}, { additionalProperties: false, $id: "ModelConfig" });
|
||||
export const ModelConfigCandidateSchema = Type.Object({
|
||||
credit_cost: Type.Integer({ minimum: 1 }),
|
||||
display_name: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
enabled: Type.Boolean(),
|
||||
error_mapping_profile: Type.Record(Type.String(), Type.String()),
|
||||
gateway_account_ref: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
is_default: Type.Boolean(),
|
||||
model_id: Type.Ref(ModelIdSchema),
|
||||
prompt_max_length: Type.Integer({ minimum: 1 }),
|
||||
recommendation_priority: Type.Number(),
|
||||
reference_limits: Type.Ref(ModelReferenceLimitsSchema),
|
||||
route_profile: Type.Record(Type.String(), Type.Unknown()),
|
||||
safety_source: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
supported_ratios: Type.Array(Type.String({ maxLength: 20, minLength: 1 }), { minItems: 1, maxItems: 8 }),
|
||||
}, { additionalProperties: false, $id: "ModelConfigCandidate" });
|
||||
export const ModelConfigurationResponseSchema = Type.Object({
|
||||
config_set_version: Type.Integer({ minimum: 1 }),
|
||||
configured_default_model_id: Type.Ref(ModelIdSchema),
|
||||
models: Type.Array(Type.Ref(ModelConfigSchema), { minItems: 3, maxItems: 3 }),
|
||||
recommended_model_id: Type.Union([Type.Ref(ModelIdSchema), Type.Null()]),
|
||||
}, { additionalProperties: false, $id: "ModelConfigurationResponse" });
|
||||
export const ModelConfigUpdateRequestSchema = Type.Object({
|
||||
expected_config_set_version: Type.Integer({ minimum: 1 }),
|
||||
models: Type.Array(Type.Ref(ModelConfigCandidateSchema), { minItems: 3, maxItems: 3 }),
|
||||
}, { additionalProperties: false, $id: "ModelConfigUpdateRequest" });
|
||||
export const ModelConfigUpdateHeadersSchema = Type.Object({
|
||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
"x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
}, { additionalProperties: true, $id: "ModelConfigUpdateHeaders" });
|
||||
export const ModelParamsSchema = Type.Object({ model_id: Type.Ref(ModelIdSchema) }, { additionalProperties: false, $id: "ModelParams" });
|
||||
|
||||
export type ModelConfig = Static<typeof ModelConfigSchema>;
|
||||
export type ModelConfigCandidate = Static<typeof ModelConfigCandidateSchema>;
|
||||
export type ModelConfigurationResponse = Static<typeof ModelConfigurationResponseSchema>;
|
||||
export type ModelConfigUpdateRequest = Static<typeof ModelConfigUpdateRequestSchema>;
|
||||
export type ModelConfigUpdateHeaders = Static<typeof ModelConfigUpdateHeadersSchema>;
|
||||
export type ModelParams = Static<typeof ModelParamsSchema>;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
import { GenerationErrorCategorySchema } from "./api.js";
|
||||
import { CanvasStateSchema } from "./canvas.js";
|
||||
|
||||
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
|
||||
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
|
||||
|
||||
export const ProjectIdSchema = Type.String({ pattern: uuidPattern, $id: "ProjectId" });
|
||||
export const ProjectRatioSchema = Type.Union(
|
||||
[Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")],
|
||||
{ $id: "ProjectRatio" },
|
||||
);
|
||||
export const ProjectViewStatusSchema = Type.Union(
|
||||
[Type.Literal("active"), Type.Literal("failed_empty"), Type.Literal("trashed")],
|
||||
{ $id: "ProjectViewStatus" },
|
||||
);
|
||||
export const ProjectSummarySchema = Type.Object(
|
||||
{
|
||||
current_image_id: Type.Union([Type.Ref(ProjectIdSchema), Type.Null()]),
|
||||
deleted_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
|
||||
name: Type.String({ maxLength: 160, minLength: 1 }),
|
||||
project_id: Type.Ref(ProjectIdSchema),
|
||||
purge_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
|
||||
ratio: Type.Ref(ProjectRatioSchema),
|
||||
state_version: Type.Integer({ minimum: 1 }),
|
||||
status: Type.Ref(ProjectViewStatusSchema),
|
||||
successful_image_count: Type.Integer({ maximum: 10, minimum: 0 }),
|
||||
updated_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectSummary" },
|
||||
);
|
||||
export const ProjectListQuerySchema = Type.Object(
|
||||
{ status: Type.Optional(Type.Union([Type.Literal("active"), Type.Literal("trashed")])) },
|
||||
{ additionalProperties: false, $id: "ProjectListQuery" },
|
||||
);
|
||||
export const ProjectListResponseSchema = Type.Object(
|
||||
{
|
||||
active_count: Type.Integer({ maximum: 20, minimum: 0 }),
|
||||
active_limit: Type.Literal(20),
|
||||
projects: Type.Array(Type.Ref(ProjectSummarySchema), { maxItems: 20 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectListResponse" },
|
||||
);
|
||||
export const ProjectParamsSchema = Type.Object(
|
||||
{ projectId: Type.Ref(ProjectIdSchema) },
|
||||
{ additionalProperties: false, $id: "ProjectParams" },
|
||||
);
|
||||
export const GenerationProjectItemSchema = Type.Object(
|
||||
{
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
error_category: Type.Union([Type.Ref(GenerationErrorCategorySchema), Type.Null()]),
|
||||
generation_id: Type.String({ pattern: uuidPattern }),
|
||||
prompt: Type.String({ maxLength: 4_000, minLength: 1 }),
|
||||
ratio: Type.Ref(ProjectRatioSchema),
|
||||
status: Type.Union([
|
||||
Type.Literal("queued"), Type.Literal("running"), Type.Literal("succeeded"),
|
||||
Type.Literal("failed"), Type.Literal("rejected"),
|
||||
]),
|
||||
updated_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "GenerationProjectItem" },
|
||||
);
|
||||
export const ProjectImageItemSchema = Type.Object(
|
||||
{
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
generation_id: Type.String({ pattern: uuidPattern }),
|
||||
image_id: Type.Ref(ProjectIdSchema),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectImageItem" },
|
||||
);
|
||||
export const ExportFormatSchema = Type.Union([Type.Literal("jpg"), Type.Literal("png")], { $id: "ExportFormat" });
|
||||
export const LatestExportItemSchema = Type.Object(
|
||||
{
|
||||
byte_size: Type.Integer({ minimum: 1 }),
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
download_url: Type.String({ pattern: "^/api/v1/projects/[0-9a-fA-F-]{36}/latest-exports/(jpg|png)$" }),
|
||||
export_id: Type.String({ pattern: uuidPattern }),
|
||||
format: Type.Ref(ExportFormatSchema),
|
||||
pixel_height: Type.Integer({ minimum: 1 }),
|
||||
pixel_width: Type.Integer({ minimum: 1 }),
|
||||
sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }),
|
||||
state_version: Type.Integer({ minimum: 1 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LatestExportItem" },
|
||||
);
|
||||
export const ProjectDetailResponseSchema = Type.Object(
|
||||
{
|
||||
...ProjectSummarySchema.properties,
|
||||
canvas_state: Type.Ref(CanvasStateSchema),
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
draft_prompt: Type.String({ maxLength: 4_000, minLength: 1 }),
|
||||
generations: Type.Array(Type.Ref(GenerationProjectItemSchema)),
|
||||
images: Type.Array(Type.Ref(ProjectImageItemSchema), { maxItems: 10 }),
|
||||
latest_exports: Type.Array(Type.Ref(LatestExportItemSchema), { maxItems: 2 }),
|
||||
pixel_height: Type.Integer({ minimum: 1 }),
|
||||
pixel_width: Type.Integer({ minimum: 1 }),
|
||||
save_status: Type.Literal("saved"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectDetailResponse" },
|
||||
);
|
||||
export const LatestExportParamsSchema = Type.Object(
|
||||
{ format: Type.Ref(ExportFormatSchema), projectId: Type.Ref(ProjectIdSchema) },
|
||||
{ additionalProperties: false, $id: "LatestExportParams" },
|
||||
);
|
||||
export const ProjectImageParamsSchema = Type.Object(
|
||||
{ imageId: Type.Ref(ProjectIdSchema), projectId: Type.Ref(ProjectIdSchema) },
|
||||
{ additionalProperties: false, $id: "ProjectImageParams" },
|
||||
);
|
||||
export const LatestExportMultipartBodySchema = Type.Object(
|
||||
{
|
||||
byte_size: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
export_file: Type.String({ format: "binary" }),
|
||||
export_id: Type.String({ pattern: uuidPattern }),
|
||||
format: Type.Ref(ExportFormatSchema),
|
||||
pixel_height: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
pixel_width: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }),
|
||||
state_version: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LatestExportMultipartBody" },
|
||||
);
|
||||
export const LatestExportSaveResponseSchema = Type.Object(
|
||||
{
|
||||
...LatestExportItemSchema.properties,
|
||||
status: Type.Literal("saved"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LatestExportSaveResponse" },
|
||||
);
|
||||
export const ProjectRenameRequestSchema = Type.Object(
|
||||
{ name: Type.String({ maxLength: 80, minLength: 1 }) },
|
||||
{ additionalProperties: false, $id: "ProjectRenameRequest" },
|
||||
);
|
||||
export const ProjectRenameResponseSchema = Type.Object(
|
||||
{
|
||||
name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
state_version: Type.Integer({ minimum: 2 }),
|
||||
status: Type.Literal("renamed"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectRenameResponse" },
|
||||
);
|
||||
export const FailedEmptyTrashRequestSchema = Type.Object(
|
||||
{ project_ids: Type.Array(Type.Ref(ProjectIdSchema), { maxItems: 20, minItems: 1, uniqueItems: true }) },
|
||||
{ additionalProperties: false, $id: "FailedEmptyTrashRequest" },
|
||||
);
|
||||
export const FailedEmptyTrashResponseSchema = Type.Object(
|
||||
{
|
||||
ignored_project_ids: Type.Array(Type.Ref(ProjectIdSchema), { maxItems: 20, uniqueItems: true }),
|
||||
trashed_project_ids: Type.Array(Type.Ref(ProjectIdSchema), { maxItems: 20, uniqueItems: true }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "FailedEmptyTrashResponse" },
|
||||
);
|
||||
export const ProjectTrashResponseSchema = Type.Object(
|
||||
{
|
||||
deleted_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
project_id: Type.Ref(ProjectIdSchema),
|
||||
purge_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("trashed"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectTrashResponse" },
|
||||
);
|
||||
export const ProjectRestoreResponseSchema = Type.Object(
|
||||
{
|
||||
deleted_at: Type.Null(),
|
||||
project_id: Type.Ref(ProjectIdSchema),
|
||||
purge_at: Type.Null(),
|
||||
status: Type.Literal("active"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectRestoreResponse" },
|
||||
);
|
||||
export const ProjectPurgeResponseSchema = Type.Object(
|
||||
{
|
||||
project_id: Type.Ref(ProjectIdSchema),
|
||||
status: Type.Literal("purged"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectPurgeResponse" },
|
||||
);
|
||||
|
||||
export type ProjectListQuery = Static<typeof ProjectListQuerySchema>;
|
||||
export type ProjectParams = Static<typeof ProjectParamsSchema>;
|
||||
export type LatestExportParams = Static<typeof LatestExportParamsSchema>;
|
||||
export type ProjectImageParams = Static<typeof ProjectImageParamsSchema>;
|
||||
export type ProjectRenameRequest = Static<typeof ProjectRenameRequestSchema>;
|
||||
export type FailedEmptyTrashRequest = Static<typeof FailedEmptyTrashRequestSchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
export const registrationNoticeSections = [
|
||||
{
|
||||
title: "外部服务",
|
||||
body: "注册验证码通过 Resend 发送;创作功能会按用户主动操作调用 AI 网关;使用 DYN004 自动定位时会调用高德处理地点文字和坐标。未使用相应功能时不会为该功能发起调用。",
|
||||
},
|
||||
{
|
||||
title: "本机存储与保护",
|
||||
body: "P0-A 数据保存在当前 Windows 电脑的 LocalDataRoot,依赖当前 Windows 用户登录和文件系统权限保护。Dada 不提供应用层加密或云备份;机器损坏、系统重装或 LocalDataRoot 被删除后数据不可恢复,重要图片请主动下载。",
|
||||
},
|
||||
{
|
||||
title: "超级管理员访问",
|
||||
body: "超级管理员可按产品权限查看运营信息,并可打开用户私有图片或完整提示词;每次打开都会记录访问审计。审计记录在规定保留期内不可人工修改或删除。",
|
||||
},
|
||||
{
|
||||
title: "DYN004 坐标",
|
||||
body: "DYN004 可在用户主动使用时处理和保存原始定位坐标;坐标属于私有项目数据,账号注销时按规则删除。高德不可用或达到应用硬上限时,自动定位停止。",
|
||||
},
|
||||
{
|
||||
title: "账号注销与保留",
|
||||
body: "账号注销后,账号资料、项目、图片、提示词、原始坐标、导出成品和未使用点数立即不可恢复。允许的匿名生成与点数事件及私有访问审计最多保留 180 天,且不得保留可回溯个人或单次作品的信息。",
|
||||
},
|
||||
{
|
||||
title: "无备份与迁移",
|
||||
body: "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。P0-A 不提供可编辑项目包或业务数据的导出、导入和迁移;用户下载的图片仅用于自行留存。",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const registrationNoticeContent = registrationNoticeSections
|
||||
.map((section) => `${section.title}\n${section.body}`)
|
||||
.join("\n\n");
|
||||
|
||||
export const registrationNotice = Object.freeze({
|
||||
content: registrationNoticeContent,
|
||||
contentSha256: "10dea4fb1b54d7208c017c126693d2061d8261bc5f8bc2b656309329ef22a791",
|
||||
effectiveAt: "2026-07-28",
|
||||
sections: registrationNoticeSections,
|
||||
title: "内测使用与隐私告知",
|
||||
version: "p0a-registration-notice-v1",
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@dada/static-sticker-catalog",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export const STATIC_STICKER_CATALOG_SCHEMA_VERSION = "StaticStickerCatalog/v1" as const;
|
||||
export const STATIC_STICKER_CATALOG_ORIGIN = "bundled_read_only" as const;
|
||||
|
||||
export interface StaticStickerThumbnailReference {
|
||||
media: "thumbnail";
|
||||
resource_id: string;
|
||||
resource_version: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalogItem {
|
||||
stable_id: string;
|
||||
part: number;
|
||||
order: number;
|
||||
original_filename: string;
|
||||
relative_path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mime_type: "image/png";
|
||||
mime: "image/png";
|
||||
sha256: string;
|
||||
original_reference: string;
|
||||
thumbnail_reference: StaticStickerThumbnailReference;
|
||||
resource_version: string;
|
||||
enabled: boolean;
|
||||
origin: "bundled_read_only" | "admin_uploaded";
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalog {
|
||||
schema_version: typeof STATIC_STICKER_CATALOG_SCHEMA_VERSION;
|
||||
release_version: string;
|
||||
manifest_sha256: string;
|
||||
count: number;
|
||||
part_counts: Record<string, number>;
|
||||
duplicate_sha256_groups: Array<{ sha256: string; stable_ids: string[] }>;
|
||||
items: StaticStickerCatalogItem[];
|
||||
}
|
||||
|
||||
export interface VirtualStickerWindowOptions {
|
||||
itemHeight: number;
|
||||
viewportHeight: number;
|
||||
columns: number;
|
||||
overscanScreens: number;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export interface VirtualStickerWindow<T extends { stable_id: string }> {
|
||||
start: number;
|
||||
end: number;
|
||||
top_spacer_px: number;
|
||||
bottom_spacer_px: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export function stableStickerId(index: number): string {
|
||||
if (!Number.isInteger(index) || index < 1) throw new Error("sticker_index_invalid");
|
||||
return `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`;
|
||||
}
|
||||
|
||||
export function getVirtualStickerWindow<T extends { stable_id: string }>(items: readonly T[], options: VirtualStickerWindowOptions): VirtualStickerWindow<T> {
|
||||
const itemHeight = Math.max(1, options.itemHeight);
|
||||
const viewportHeight = Math.max(1, options.viewportHeight);
|
||||
const columns = Math.max(1, Math.floor(options.columns));
|
||||
const overscanScreens = Math.max(0, options.overscanScreens);
|
||||
const scrollTop = Math.max(0, options.scrollTop);
|
||||
const totalRows = Math.ceil(items.length / columns);
|
||||
const visibleRows = Math.max(1, Math.ceil(viewportHeight / itemHeight));
|
||||
const overscanRows = Math.ceil((visibleRows * overscanScreens) / 2);
|
||||
const firstVisibleRow = Math.min(totalRows, Math.floor(scrollTop / itemHeight));
|
||||
const startRow = Math.max(0, firstVisibleRow - overscanRows);
|
||||
const endRow = Math.min(totalRows, firstVisibleRow + visibleRows + overscanRows);
|
||||
const start = startRow * columns;
|
||||
const end = Math.min(items.length, endRow * columns);
|
||||
return {
|
||||
bottom_spacer_px: Math.max(0, (totalRows - endRow) * itemHeight),
|
||||
end,
|
||||
items: items.slice(start, end) as T[],
|
||||
start,
|
||||
top_spacer_px: startRow * itemHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function staticStickerThumbnailUrl(item: Pick<StaticStickerCatalogItem, "resource_version" | "stable_id">): string {
|
||||
return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}?variant=thumbnail`;
|
||||
}
|
||||
|
||||
export function staticStickerOriginalUrl(item: Pick<StaticStickerCatalogItem, "resource_version" | "stable_id">): string {
|
||||
return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user