Initial WallMuse project
This commit is contained in:
22
packages/api-client/package.json
Executable file
22
packages/api-client/package.json
Executable file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@wallmuse/api-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wallmuse/shared": "workspace:*",
|
||||
"zod": "^3.24.4"
|
||||
}
|
||||
}
|
||||
390
packages/api-client/src/index.ts
Executable file
390
packages/api-client/src/index.ts
Executable file
@@ -0,0 +1,390 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
apiPaths,
|
||||
AppConfigResponseSchema,
|
||||
CreateGenerationRequestSchema,
|
||||
CreateGenerationResponseSchema,
|
||||
GenerationGroupSchema,
|
||||
ModelSummarySchema,
|
||||
ProviderSummarySchema,
|
||||
WebGenerationGroupSchema,
|
||||
WebUserApiKeySchema,
|
||||
WebUserSchema,
|
||||
WebWallpaperSchema,
|
||||
type CreateGenerationRequest,
|
||||
type WebCreateGenerationInput,
|
||||
type WebGenerationGroup,
|
||||
type WebSaveApiKeyInput,
|
||||
type WebUser,
|
||||
type WebUserApiKey,
|
||||
type WebWallpaper
|
||||
} from "@wallmuse/shared";
|
||||
|
||||
export type WallMuseApiClientOptions = {
|
||||
baseUrl?: string;
|
||||
accessToken?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
export class WallMuseApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly body: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WallMuseApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export class WallMuseApiClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly accessToken: string | undefined;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly headers: HeadersInit | undefined;
|
||||
|
||||
constructor(options: WallMuseApiClientOptions = {}) {
|
||||
this.baseUrl = options.baseUrl?.replace(/\/$/, "") ?? "";
|
||||
this.accessToken = options.accessToken;
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.headers = options.headers;
|
||||
}
|
||||
|
||||
getAppConfig() {
|
||||
return this.request(apiPaths.appConfig, AppConfigResponseSchema);
|
||||
}
|
||||
|
||||
getProviders() {
|
||||
return this.request(apiPaths.providers, z.array(ProviderSummarySchema));
|
||||
}
|
||||
|
||||
getModels() {
|
||||
return this.request(apiPaths.models, z.array(ModelSummarySchema));
|
||||
}
|
||||
|
||||
createGeneration(input: CreateGenerationRequest) {
|
||||
const body = CreateGenerationRequestSchema.parse(input);
|
||||
return this.request(apiPaths.generations, CreateGenerationResponseSchema, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
getGenerationGroup(id: string) {
|
||||
return this.request(apiPaths.generationGroup(id), GenerationGroupSchema);
|
||||
}
|
||||
|
||||
getCurrentUser(): Promise<WebUser> {
|
||||
return this.request("/api/v1/me", WebUserSchema);
|
||||
}
|
||||
|
||||
listWallpapers(): Promise<WebWallpaper[]> {
|
||||
return this.request("/api/v1/wallpapers", z.array(WebWallpaperSchema));
|
||||
}
|
||||
|
||||
listGenerationHistory(): Promise<WebGenerationGroup[]> {
|
||||
return this.request("/api/v1/generation-groups", z.array(WebGenerationGroupSchema));
|
||||
}
|
||||
|
||||
createWebGeneration(input: WebCreateGenerationInput): Promise<WebGenerationGroup> {
|
||||
return this.request("/api/v1/generations", WebGenerationGroupSchema, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
getWebGenerationGroup(id: string): Promise<WebGenerationGroup> {
|
||||
return this.request(`/api/v1/generation-groups/${id}`, WebGenerationGroupSchema);
|
||||
}
|
||||
|
||||
listApiKeys(): Promise<WebUserApiKey[]> {
|
||||
return this.request("/api/v1/user-api-keys", z.array(WebUserApiKeySchema));
|
||||
}
|
||||
|
||||
saveApiKey(input: WebSaveApiKeyInput): Promise<WebUserApiKey> {
|
||||
return this.request("/api/v1/user-api-keys", WebUserApiKeySchema, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
private async request<TSchema extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: TSchema,
|
||||
init: RequestInit = {}
|
||||
): Promise<z.output<TSchema>> {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(this.accessToken ? { authorization: `Bearer ${this.accessToken}` } : {}),
|
||||
...this.headers,
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const body = text.length > 0 ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new WallMuseApiError(`WallMuse API request failed: ${response.status}`, response.status, body);
|
||||
}
|
||||
|
||||
return schema.parse(body) as z.output<TSchema>;
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeEnv = {
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
const getPublicApiBaseUrl = (): string =>
|
||||
(globalThis as RuntimeEnv).process?.env?.NEXT_PUBLIC_WALLMUSE_API_BASE ?? "";
|
||||
|
||||
const publicApiBaseUrl = getPublicApiBaseUrl();
|
||||
|
||||
const browserClient = new WallMuseApiClient({
|
||||
baseUrl: publicApiBaseUrl,
|
||||
fetchImpl: async (input, init) => {
|
||||
if (!publicApiBaseUrl) {
|
||||
return mockFetch(input, init);
|
||||
}
|
||||
return fetch(input, { ...init, credentials: "include" });
|
||||
}
|
||||
});
|
||||
|
||||
export const wallMuseApi = {
|
||||
getCurrentUser: () => browserClient.getCurrentUser(),
|
||||
listWallpapers: () => browserClient.listWallpapers(),
|
||||
listGenerationHistory: () => browserClient.listGenerationHistory(),
|
||||
createGeneration: (input: WebCreateGenerationInput) => browserClient.createWebGeneration(input),
|
||||
getGenerationGroup: (id: string) => browserClient.getWebGenerationGroup(id),
|
||||
listApiKeys: () => browserClient.listApiKeys(),
|
||||
saveApiKey: (input: WebSaveApiKeyInput) => browserClient.saveApiKey(input)
|
||||
};
|
||||
|
||||
const image = (id: string, width: number, height: number) =>
|
||||
`https://images.unsplash.com/${id}?auto=format&fit=crop&w=${width}&h=${height}&q=86`;
|
||||
|
||||
const mockModelId = "00000000-0000-4000-8000-000000000101";
|
||||
|
||||
const mockUser: WebUser = {
|
||||
id: "usr_demo",
|
||||
name: "Feng Lee",
|
||||
email: "feng@example.com",
|
||||
avatarInitials: "FL",
|
||||
theme: "system"
|
||||
};
|
||||
|
||||
const mockWallpapers: WebWallpaper[] = [
|
||||
{
|
||||
id: "wp-aurora",
|
||||
title: "Aurora Glass Valley",
|
||||
prompt: "A crystalline valley under soft aurora lights, cinematic wallpaper",
|
||||
imageUrl: image("photo-1500530855697-b586d89ba3ee", 1200, 900),
|
||||
ratio: "4:3",
|
||||
resolution: "4k",
|
||||
style: "Nature",
|
||||
model: "Seedream",
|
||||
likes: 1842,
|
||||
downloads: 930,
|
||||
colors: ["#9bd5ff", "#eaf7ff"],
|
||||
createdAt: "2026-05-09T08:00:00.000Z",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: "wp-desktop-mist",
|
||||
title: "Misty Desktop Lake",
|
||||
prompt: "A calm alpine lake with glass reflections and airy blue mist",
|
||||
imageUrl: image("photo-1501785888041-af3ef285b470", 1440, 810),
|
||||
ratio: "16:9",
|
||||
resolution: "4k",
|
||||
style: "Landscape",
|
||||
model: "Qwen Image",
|
||||
likes: 1260,
|
||||
downloads: 704,
|
||||
colors: ["#d8ecff", "#607d9b"],
|
||||
createdAt: "2026-05-09T08:10:00.000Z"
|
||||
},
|
||||
{
|
||||
id: "wp-mobile-neon",
|
||||
title: "Blue Neon Alley",
|
||||
prompt: "A rainy futuristic alley with low saturation blue neon",
|
||||
imageUrl: image("photo-1519608487953-e999c86e7455", 900, 1600),
|
||||
ratio: "9:16",
|
||||
resolution: "2k",
|
||||
style: "Cyberpunk",
|
||||
model: "OpenAI Compatible",
|
||||
likes: 902,
|
||||
downloads: 388,
|
||||
colors: ["#0e2a4e", "#66aaff"],
|
||||
createdAt: "2026-05-09T08:20:00.000Z"
|
||||
},
|
||||
{
|
||||
id: "wp-minimal-wave",
|
||||
title: "Minimal Ice Wave",
|
||||
prompt: "Minimal translucent ice waves, soft studio light, clean wallpaper",
|
||||
imageUrl: image("photo-1493246507139-91e8fad9978e", 1200, 900),
|
||||
ratio: "4:3",
|
||||
resolution: "2k",
|
||||
style: "Minimal",
|
||||
model: "FLUX",
|
||||
likes: 740,
|
||||
downloads: 284,
|
||||
colors: ["#f4fbff", "#b5cbe2"],
|
||||
createdAt: "2026-05-09T08:30:00.000Z"
|
||||
},
|
||||
{
|
||||
id: "wp-space-dawn",
|
||||
title: "Orbital Dawn",
|
||||
prompt: "A quiet orbital sunrise above a blue planet, premium wallpaper",
|
||||
imageUrl: image("photo-1446776811953-b23d57bd21aa", 1440, 810),
|
||||
ratio: "16:9",
|
||||
resolution: "4k",
|
||||
style: "Space",
|
||||
model: "Seedream",
|
||||
likes: 2014,
|
||||
downloads: 1160,
|
||||
colors: ["#07111f", "#66aaff"],
|
||||
createdAt: "2026-05-09T08:40:00.000Z",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: "wp-architecture",
|
||||
title: "Soft Concrete Atrium",
|
||||
prompt: "Modern architectural atrium, cool daylight, gentle reflections",
|
||||
imageUrl: image("photo-1497366754035-f200968a6e72", 1200, 900),
|
||||
ratio: "4:3",
|
||||
resolution: "2k",
|
||||
style: "Architecture",
|
||||
model: "Qwen Image",
|
||||
likes: 580,
|
||||
downloads: 210,
|
||||
colors: ["#eef6ff", "#8293a8"],
|
||||
createdAt: "2026-05-09T08:50:00.000Z"
|
||||
}
|
||||
];
|
||||
|
||||
const mockGenerationHistory: WebGenerationGroup[] = [
|
||||
{
|
||||
id: "gen-blue-canyon",
|
||||
prompt: "A blue glass canyon with a silent river and soft morning haze",
|
||||
status: "succeeded",
|
||||
style: "Landscape",
|
||||
model: "Seedream",
|
||||
resolution: "4k",
|
||||
consistencyScore: 94,
|
||||
createdAt: "2026-05-09T09:00:00.000Z",
|
||||
assets: [
|
||||
{
|
||||
id: "asset-desktop",
|
||||
label: "Desktop",
|
||||
ratio: "16:9",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
imageUrl: image("photo-1500534314209-a25ddb2bd429", 1440, 810)
|
||||
},
|
||||
{
|
||||
id: "asset-mobile",
|
||||
label: "Mobile",
|
||||
ratio: "9:16",
|
||||
width: 2160,
|
||||
height: 3840,
|
||||
imageUrl: image("photo-1500534314209-a25ddb2bd429", 900, 1600)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const mockApiKeys: WebUserApiKey[] = [
|
||||
{
|
||||
id: "key-openai-compatible",
|
||||
provider: "OpenAI Compatible",
|
||||
baseUrl: "https://api.wallmuse-dev.example/v1",
|
||||
model: "gpt-image-1",
|
||||
maskedKey: "sk-...R7m",
|
||||
isDefault: true,
|
||||
status: "connected",
|
||||
updatedAt: "2026-05-09T08:45:00.000Z"
|
||||
}
|
||||
];
|
||||
|
||||
async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const path = url.startsWith("http") ? new URL(url).pathname : url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (path === "/api/v1/me") return json(mockUser);
|
||||
if (path === "/api/v1/wallpapers") return json(mockWallpapers);
|
||||
if (path === "/api/v1/generation-groups") return json(mockGenerationHistory);
|
||||
if (path.startsWith("/api/v1/generation-groups/")) {
|
||||
const id = path.split("/").pop();
|
||||
return json(mockGenerationHistory.find((item) => item.id === id) ?? mockGenerationHistory[0]);
|
||||
}
|
||||
if (path === "/api/v1/generations" && method === "POST") {
|
||||
return json(createMockGeneration(init?.body ? JSON.parse(String(init.body)) : undefined));
|
||||
}
|
||||
if (path === "/api/v1/user-api-keys") {
|
||||
if (method === "POST") {
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as WebSaveApiKeyInput) : undefined;
|
||||
return json({
|
||||
id: "key-new-api",
|
||||
provider: body?.provider ?? "OpenAI Compatible",
|
||||
baseUrl: body?.baseUrl ?? "https://api.example.com/v1",
|
||||
model: body?.model ?? "gpt-image-1",
|
||||
maskedKey: "sk-...9Kp",
|
||||
isDefault: body?.isDefault ?? true,
|
||||
status: "connected",
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
return json(mockApiKeys);
|
||||
}
|
||||
|
||||
return json({ error: `Mock API route not implemented: ${path}` }, 404);
|
||||
}
|
||||
|
||||
function createMockGeneration(input?: WebCreateGenerationInput): WebGenerationGroup {
|
||||
return {
|
||||
id: "gen-live-preview",
|
||||
prompt: input?.prompt ?? "A clean blue wallpaper with glass light",
|
||||
negativePrompt: input?.negativePrompt,
|
||||
status: "succeeded",
|
||||
style: input?.style ?? "Nature",
|
||||
model: input?.model ?? mockModelId,
|
||||
resolution: input?.resolution ?? "2k",
|
||||
consistencyScore: 91,
|
||||
createdAt: new Date().toISOString(),
|
||||
assets: [
|
||||
{
|
||||
id: "asset-live-desktop",
|
||||
label: "Desktop",
|
||||
ratio: "16:9",
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
imageUrl: image("photo-1506744038136-46273834b3fb", 1440, 810)
|
||||
},
|
||||
{
|
||||
id: "asset-live-mobile",
|
||||
label: "Mobile",
|
||||
ratio: "9:16",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
imageUrl: image("photo-1506744038136-46273834b3fb", 900, 1600)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
export * from "@wallmuse/shared";
|
||||
10
packages/api-client/tsconfig.json
Executable file
10
packages/api-client/tsconfig.json
Executable file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
20
packages/db/package.json
Executable file
20
packages/db/package.json
Executable file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@wallmuse/db",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"prisma": "prisma --schema prisma/schema.prisma",
|
||||
"prisma:validate": "prisma validate --schema prisma/schema.prisma",
|
||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.7.0"
|
||||
}
|
||||
}
|
||||
457
packages/db/prisma/schema.prisma
Normal file
457
packages/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,457 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
user
|
||||
admin
|
||||
super_admin
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
active
|
||||
disabled
|
||||
deleted
|
||||
}
|
||||
|
||||
enum ProviderStatus {
|
||||
disabled
|
||||
healthy
|
||||
degraded
|
||||
error
|
||||
}
|
||||
|
||||
enum ModelStatus {
|
||||
draft
|
||||
enabled
|
||||
disabled
|
||||
deprecated
|
||||
}
|
||||
|
||||
enum ApiKeyMode {
|
||||
platform
|
||||
user_own
|
||||
hybrid
|
||||
}
|
||||
|
||||
enum AuthType {
|
||||
bearer
|
||||
api_key
|
||||
custom
|
||||
}
|
||||
|
||||
enum GenerationMode {
|
||||
text_to_image
|
||||
image_to_image
|
||||
}
|
||||
|
||||
enum GenerationQuality {
|
||||
standard
|
||||
hd
|
||||
ultra
|
||||
}
|
||||
|
||||
enum ResolutionTier {
|
||||
one_k @map("1k")
|
||||
two_k @map("2k")
|
||||
four_k @map("4k")
|
||||
}
|
||||
|
||||
enum AspectRatio {
|
||||
square @map("1:1")
|
||||
landscape_4_3 @map("4:3")
|
||||
portrait_3_4 @map("3:4")
|
||||
landscape_16_9 @map("16:9")
|
||||
portrait_9_16 @map("9:16")
|
||||
ultrawide_21_9 @map("21:9")
|
||||
}
|
||||
|
||||
enum GenerationGroupStatus {
|
||||
queued
|
||||
running
|
||||
partial_succeeded
|
||||
succeeded
|
||||
failed
|
||||
canceled
|
||||
}
|
||||
|
||||
enum GenerationTaskStatus {
|
||||
created
|
||||
queued
|
||||
dispatching
|
||||
running
|
||||
uploading
|
||||
post_processing
|
||||
moderating
|
||||
succeeded
|
||||
failed
|
||||
retrying
|
||||
canceled
|
||||
expired
|
||||
}
|
||||
|
||||
enum AssetKind {
|
||||
reference
|
||||
master
|
||||
landscape
|
||||
portrait
|
||||
thumbnail
|
||||
preview
|
||||
download_zip
|
||||
}
|
||||
|
||||
enum AssetStatus {
|
||||
temporary
|
||||
active
|
||||
deleted
|
||||
failed
|
||||
}
|
||||
|
||||
enum WallpaperStatus {
|
||||
draft
|
||||
published
|
||||
hidden
|
||||
rejected
|
||||
deleted
|
||||
}
|
||||
|
||||
enum ModerationStatus {
|
||||
pending
|
||||
passed
|
||||
rejected
|
||||
manual_review
|
||||
}
|
||||
|
||||
enum ProviderCallStatus {
|
||||
started
|
||||
success
|
||||
failed
|
||||
}
|
||||
|
||||
enum DevicePlatform {
|
||||
web
|
||||
ios
|
||||
android
|
||||
desktop
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
email String @unique @db.VarChar(255)
|
||||
name String? @db.VarChar(80)
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
roles UserRole[] @default([user])
|
||||
status UserStatus @default(active)
|
||||
userApiKeys UserApiKey[]
|
||||
generationGroups GenerationGroup[]
|
||||
wallpapers Wallpaper[]
|
||||
preferences UserPreference?
|
||||
devices Device[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserApiKey {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
providerId String @map("provider_id") @db.Uuid
|
||||
name String @db.VarChar(80)
|
||||
maskedKey String @map("masked_key") @db.VarChar(120)
|
||||
encryptedKey String @map("encrypted_key") @db.Text
|
||||
baseUrl String? @map("base_url") @db.VarChar(512)
|
||||
defaultModelId String? @map("default_model_id") @db.Uuid
|
||||
enabled Boolean @default(true)
|
||||
metadata Json @default("{}")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
provider Provider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||
defaultModel Model? @relation(fields: [defaultModelId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@index([userId, enabled])
|
||||
@@index([providerId])
|
||||
@@map("user_api_keys")
|
||||
}
|
||||
|
||||
model Provider {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
slug String @unique @db.VarChar(80)
|
||||
displayName String @map("display_name") @db.VarChar(120)
|
||||
baseUrl String? @map("base_url") @db.VarChar(512)
|
||||
authType AuthType @default(bearer) @map("auth_type")
|
||||
status ProviderStatus @default(healthy)
|
||||
keyMode ApiKeyMode @default(hybrid) @map("key_mode")
|
||||
supportsUserKeys Boolean @default(true) @map("supports_user_keys")
|
||||
supportsPlatformKeys Boolean @default(true) @map("supports_platform_keys")
|
||||
healthCheckPath String? @map("health_check_path") @db.VarChar(255)
|
||||
rateLimitPerMinute Int? @map("rate_limit_per_minute")
|
||||
metadata Json @default("{}")
|
||||
models Model[]
|
||||
userApiKeys UserApiKey[]
|
||||
generationGroups GenerationGroup[]
|
||||
providerCallLogs ProviderCallLog[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("providers")
|
||||
}
|
||||
|
||||
model Model {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
providerId String @map("provider_id") @db.Uuid
|
||||
slug String @db.VarChar(160)
|
||||
displayName String @map("display_name") @db.VarChar(160)
|
||||
status ModelStatus @default(enabled)
|
||||
keyMode ApiKeyMode @default(hybrid) @map("key_mode")
|
||||
capability Json
|
||||
pricing Json?
|
||||
defaultParams Json @default("{}") @map("default_params")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||||
sizePresets ModelSizePreset[]
|
||||
userApiKeys UserApiKey[]
|
||||
generationGroups GenerationGroup[]
|
||||
generationTasks GenerationTask[]
|
||||
generatedAssets GeneratedAsset[]
|
||||
providerCallLogs ProviderCallLog[]
|
||||
wallpapers Wallpaper[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([providerId, slug])
|
||||
@@index([status, sortOrder])
|
||||
@@map("models")
|
||||
}
|
||||
|
||||
model ModelSizePreset {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
modelId String @map("model_id") @db.Uuid
|
||||
aspectRatio AspectRatio @map("aspect_ratio")
|
||||
resolution ResolutionTier
|
||||
width Int
|
||||
height Int
|
||||
providerSizeValue String? @map("provider_size_value") @db.VarChar(80)
|
||||
native Boolean @default(true)
|
||||
requiresUpscale Boolean @default(false) @map("requires_upscale")
|
||||
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([modelId, aspectRatio, resolution])
|
||||
@@map("model_size_presets")
|
||||
}
|
||||
|
||||
model GenerationGroup {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
providerId String @map("provider_id") @db.Uuid
|
||||
modelId String @map("model_id") @db.Uuid
|
||||
userApiKeyId String? @map("user_api_key_id") @db.Uuid
|
||||
status GenerationGroupStatus @default(queued)
|
||||
mode GenerationMode
|
||||
prompt String @db.Text
|
||||
negativePrompt String? @map("negative_prompt") @db.Text
|
||||
resolution ResolutionTier
|
||||
quality GenerationQuality @default(standard)
|
||||
batchSize Int @default(1) @map("batch_size")
|
||||
seed Int?
|
||||
privacy String @default("private") @db.VarChar(24)
|
||||
metadata Json @default("{}")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
provider Provider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||
model Model @relation(fields: [modelId], references: [id], onDelete: Restrict)
|
||||
tasks GenerationTask[]
|
||||
assets GeneratedAsset[]
|
||||
wallpapers Wallpaper[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([status, createdAt])
|
||||
@@map("generation_groups")
|
||||
}
|
||||
|
||||
model GenerationTask {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
groupId String @map("group_id") @db.Uuid
|
||||
modelId String @map("model_id") @db.Uuid
|
||||
status GenerationTaskStatus @default(created)
|
||||
mode GenerationMode
|
||||
aspectRatio AspectRatio @map("aspect_ratio")
|
||||
resolution ResolutionTier
|
||||
quality GenerationQuality @default(standard)
|
||||
priority Int @default(0)
|
||||
attempt Int @default(0)
|
||||
maxAttempts Int @default(3) @map("max_attempts")
|
||||
progress Int @default(0)
|
||||
errorCode String? @map("error_code") @db.VarChar(120)
|
||||
errorMessage String? @map("error_message") @db.Text
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
group GenerationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
model Model @relation(fields: [modelId], references: [id], onDelete: Restrict)
|
||||
assets GeneratedAsset[]
|
||||
providerCallLogs ProviderCallLog[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([status, priority, createdAt], map: "idx_generation_tasks_status_priority")
|
||||
@@index([groupId])
|
||||
@@map("generation_tasks")
|
||||
}
|
||||
|
||||
model GeneratedAsset {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
groupId String @map("group_id") @db.Uuid
|
||||
taskId String? @map("task_id") @db.Uuid
|
||||
providerId String? @map("provider_id") @db.Uuid
|
||||
modelId String? @map("model_id") @db.Uuid
|
||||
kind AssetKind
|
||||
status AssetStatus @default(temporary)
|
||||
width Int?
|
||||
height Int?
|
||||
mimeType String? @map("mime_type") @db.VarChar(120)
|
||||
bucket String? @db.VarChar(120)
|
||||
objectKey String? @map("object_key") @db.VarChar(512)
|
||||
storageUrl String? @map("storage_url") @db.Text
|
||||
publicUrl String? @map("public_url") @db.Text
|
||||
blurHash String? @map("blur_hash") @db.VarChar(255)
|
||||
byteSize BigInt? @map("byte_size")
|
||||
sha256 String? @db.VarChar(64)
|
||||
metadata Json @default("{}")
|
||||
group GenerationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
task GenerationTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||
model Model? @relation(fields: [modelId], references: [id], onDelete: SetNull)
|
||||
wallpapers Wallpaper[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([groupId])
|
||||
@@index([taskId])
|
||||
@@map("generated_assets")
|
||||
}
|
||||
|
||||
model Wallpaper {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
groupId String? @map("group_id") @db.Uuid
|
||||
assetId String? @map("asset_id") @db.Uuid
|
||||
modelId String? @map("model_id") @db.Uuid
|
||||
title String @db.VarChar(160)
|
||||
description String? @db.Text
|
||||
prompt String? @db.Text
|
||||
status WallpaperStatus @default(draft)
|
||||
isFeatured Boolean @default(false) @map("is_featured")
|
||||
tags String[] @default([])
|
||||
metadata Json @default("{}")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
group GenerationGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
asset GeneratedAsset? @relation(fields: [assetId], references: [id], onDelete: SetNull)
|
||||
model Model? @relation(fields: [modelId], references: [id], onDelete: SetNull)
|
||||
moderationRecords ModerationRecord[]
|
||||
publishedAt DateTime? @map("published_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([status, isFeatured, publishedAt], map: "idx_wallpapers_status_featured_published")
|
||||
@@index([userId, createdAt])
|
||||
@@map("wallpapers")
|
||||
}
|
||||
|
||||
model ModerationRecord {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
wallpaperId String? @map("wallpaper_id") @db.Uuid
|
||||
assetId String? @map("asset_id") @db.Uuid
|
||||
status ModerationStatus @default(pending)
|
||||
reason String? @db.Text
|
||||
metadata Json @default("{}")
|
||||
reviewerId String? @map("reviewer_id") @db.Uuid
|
||||
wallpaper Wallpaper? @relation(fields: [wallpaperId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@map("moderation_records")
|
||||
}
|
||||
|
||||
model ProviderCallLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
taskId String? @map("task_id") @db.Uuid
|
||||
providerId String @map("provider_id") @db.Uuid
|
||||
modelId String? @map("model_id") @db.Uuid
|
||||
userApiKeyId String? @map("user_api_key_id") @db.Uuid
|
||||
status ProviderCallStatus
|
||||
latencyMs Int? @map("latency_ms")
|
||||
errorCode String? @map("error_code") @db.VarChar(120)
|
||||
errorMessage String? @map("error_message") @db.Text
|
||||
requestId String? @map("request_id") @db.VarChar(160)
|
||||
providerCost Decimal? @map("provider_cost") @db.Decimal(12, 6)
|
||||
metadata Json @default("{}")
|
||||
task GenerationTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||
provider Provider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||
model Model? @relation(fields: [modelId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([taskId, createdAt], map: "idx_provider_logs_task")
|
||||
@@index([providerId, createdAt])
|
||||
@@map("provider_call_logs")
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @unique @map("user_id") @db.Uuid
|
||||
defaultModelId String? @map("default_model_id") @db.Uuid
|
||||
defaultResolution ResolutionTier? @map("default_resolution")
|
||||
defaultAspectRatios String[] @default([]) @map("default_aspect_ratios")
|
||||
defaultPrivacy String @default("private") @map("default_privacy") @db.VarChar(24)
|
||||
metadata Json @default("{}")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
platform DevicePlatform
|
||||
appVersion String? @map("app_version") @db.VarChar(40)
|
||||
pushToken String? @map("push_token") @db.Text
|
||||
metadata Json @default("{}")
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([userId, platform])
|
||||
@@map("devices")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
title String @db.VarChar(160)
|
||||
body String @db.Text
|
||||
readAt DateTime? @map("read_at")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId, readAt, createdAt])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
model AppConfig {
|
||||
key String @id @db.VarChar(120)
|
||||
value Json
|
||||
description String? @db.Text
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("app_config")
|
||||
}
|
||||
1
packages/db/src/index.ts
Executable file
1
packages/db/src/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export * from "./json-store";
|
||||
523
packages/db/src/json-store.ts
Executable file
523
packages/db/src/json-store.ts
Executable file
@@ -0,0 +1,523 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type {
|
||||
AppConfigResponse,
|
||||
AssetKind,
|
||||
GeneratedAsset,
|
||||
GenerationGroup,
|
||||
GenerationGroupStatus,
|
||||
GenerationTaskStatus,
|
||||
ModelSummary,
|
||||
ProviderSummary
|
||||
} from "@wallmuse/shared";
|
||||
|
||||
type JsonUserRole = "user" | "admin" | "super_admin";
|
||||
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
roles: JsonUserRole[];
|
||||
passwordHash: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserApiKeyRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
providerId: string;
|
||||
name: string;
|
||||
maskedKey: string;
|
||||
encryptedKey: string;
|
||||
baseUrl?: string;
|
||||
defaultModelId?: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderRecord extends ProviderSummary {
|
||||
baseUrl?: string;
|
||||
authType: "bearer" | "api_key" | "custom";
|
||||
supportsUserKeys: boolean;
|
||||
supportsPlatformKeys: boolean;
|
||||
healthCheckPath?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderCallLogRecord {
|
||||
id: string;
|
||||
taskId?: string;
|
||||
groupId?: string;
|
||||
providerId: string;
|
||||
modelId?: string;
|
||||
assetKind?: AssetKind;
|
||||
status: "started" | "succeeded" | "success" | "failed";
|
||||
attempt?: number;
|
||||
latencyMs?: number;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
usage?: unknown;
|
||||
rawMetadata?: unknown;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface GenerationWorkerJobData {
|
||||
groupId: string;
|
||||
taskId?: string;
|
||||
userId: string;
|
||||
mode: "text_to_image" | "image_to_image";
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
resolution: "1k" | "2k" | "4k";
|
||||
providerId: string;
|
||||
providerSlug: string;
|
||||
modelId: string;
|
||||
modelSlug: string;
|
||||
referenceAssetUrl?: string;
|
||||
retryAssetKind?: Extract<AssetKind, "master" | "landscape" | "portrait">;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface StoredProviderAssetInput {
|
||||
groupId: string;
|
||||
taskId?: string;
|
||||
userId: string;
|
||||
assetKind: Extract<AssetKind, "master" | "landscape" | "portrait">;
|
||||
status: "active" | "failed";
|
||||
width?: number;
|
||||
height?: number;
|
||||
mimeType?: string;
|
||||
publicUrl?: string;
|
||||
storageUrl?: string;
|
||||
objectKey?: string;
|
||||
bucket?: string;
|
||||
byteSize?: number;
|
||||
sha256?: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface StoredGenerationGroup extends GenerationGroup {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
privacy: "private" | "public";
|
||||
}
|
||||
|
||||
export interface DataStoreShape {
|
||||
appConfigUpdatedAt: string;
|
||||
users: UserRecord[];
|
||||
providers: ProviderRecord[];
|
||||
models: ModelSummary[];
|
||||
userApiKeys: UserApiKeyRecord[];
|
||||
generationGroups: StoredGenerationGroup[];
|
||||
providerCallLogs: ProviderCallLogRecord[];
|
||||
}
|
||||
|
||||
const nowIso = (): string => new Date().toISOString();
|
||||
const mockProviderId = "00000000-0000-4000-8000-000000000001";
|
||||
const mockModelId = "00000000-0000-4000-8000-000000000101";
|
||||
|
||||
const createDefaultProvider = (now: string): ProviderRecord => ({
|
||||
id: mockProviderId,
|
||||
slug: "mock",
|
||||
displayName: "Mock Provider",
|
||||
status: "healthy",
|
||||
keyMode: "hybrid",
|
||||
modelCount: 1,
|
||||
authType: "bearer",
|
||||
supportsUserKeys: true,
|
||||
supportsPlatformKeys: true,
|
||||
healthCheckPath: "/v1/models",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const createDefaultModel = (): ModelSummary => ({
|
||||
id: mockModelId,
|
||||
providerId: mockProviderId,
|
||||
slug: "mock-wallpaper-v1",
|
||||
displayName: "Mock Wallpaper v1",
|
||||
status: "enabled",
|
||||
keyMode: "hybrid",
|
||||
sortOrder: 0,
|
||||
capability: {
|
||||
supportsTextToImage: true,
|
||||
supportsImageToImage: true,
|
||||
supportsEdit: false,
|
||||
supportsNegativePrompt: true,
|
||||
supportsSeed: true,
|
||||
supportsBatch: false,
|
||||
supportsStreaming: false,
|
||||
supportsBase64Result: false,
|
||||
supportsUrlResult: true,
|
||||
supportsNative4k: false,
|
||||
maxBatchSize: 1,
|
||||
maxInputImages: 3,
|
||||
maxPromptLength: 4000,
|
||||
maxNegativePromptLength: 2000,
|
||||
maxPixels: 3686400,
|
||||
supportedAspectRatios: ["16:9", "9:16", "1:1"],
|
||||
supportedResolutions: ["1k", "2k", "4k"],
|
||||
sizePresets: [
|
||||
{
|
||||
aspectRatio: "16:9",
|
||||
resolution: "2k",
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
providerSizeValue: "2560x1440",
|
||||
native: true,
|
||||
requiresUpscale: false
|
||||
},
|
||||
{
|
||||
aspectRatio: "9:16",
|
||||
resolution: "2k",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
providerSizeValue: "1440x2560",
|
||||
native: true,
|
||||
requiresUpscale: false
|
||||
}
|
||||
],
|
||||
defaultParams: {}
|
||||
},
|
||||
pricing: {
|
||||
currency: "USD",
|
||||
unit: "image",
|
||||
amount: 0,
|
||||
estimatedCredits: 1
|
||||
}
|
||||
});
|
||||
|
||||
const emptyData = (): DataStoreShape => ({
|
||||
appConfigUpdatedAt: nowIso(),
|
||||
users: [],
|
||||
providers: [],
|
||||
models: [],
|
||||
userApiKeys: [],
|
||||
generationGroups: [],
|
||||
providerCallLogs: []
|
||||
});
|
||||
|
||||
export class JsonWallMuseDb {
|
||||
private writeQueue = Promise.resolve();
|
||||
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
static fromEnv(): JsonWallMuseDb {
|
||||
const dataDir = process.env.WALLMUSE_DATA_DIR ?? join(process.cwd(), ".data");
|
||||
return new JsonWallMuseDb(join(dataDir, "wallmuse-api.json"));
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const data = await this.read();
|
||||
if (!data.providers.some((provider) => provider.slug === "mock")) {
|
||||
const now = nowIso();
|
||||
data.providers.push(createDefaultProvider(now));
|
||||
data.models.push(createDefaultModel());
|
||||
await this.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
async read(): Promise<DataStoreShape> {
|
||||
try {
|
||||
const text = await readFile(this.filePath, "utf8");
|
||||
const parsed = JSON.parse(text) as Partial<DataStoreShape>;
|
||||
return { ...emptyData(), ...parsed };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return emptyData();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async mutate<T>(fn: (data: DataStoreShape) => T | Promise<T>): Promise<T> {
|
||||
const run = async () => {
|
||||
const data = await this.read();
|
||||
const result = await fn(data);
|
||||
await this.write(data);
|
||||
return result;
|
||||
};
|
||||
const next = this.writeQueue.then(run, run);
|
||||
this.writeQueue = next.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
getAppConfig(data: DataStoreShape): AppConfigResponse {
|
||||
return {
|
||||
site: { name: "WallMuse" },
|
||||
generation: {
|
||||
defaultModelId: data.models[0]?.id,
|
||||
defaultAspectRatios: ["16:9", "9:16"],
|
||||
defaultResolution: "2k",
|
||||
maxBatchSize: 4,
|
||||
allowedResolutions: ["1k", "2k", "4k"]
|
||||
},
|
||||
features: {
|
||||
authEnabled: true,
|
||||
galleryEnabled: true,
|
||||
userApiKeysEnabled: true,
|
||||
generationEnabled: true,
|
||||
darkModeEnabled: true
|
||||
},
|
||||
providers: data.providers,
|
||||
models: data.models,
|
||||
updatedAt: data.appConfigUpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
async seedQueuedGeneration(job: GenerationWorkerJobData): Promise<void> {
|
||||
await this.init();
|
||||
const now = nowIso();
|
||||
await this.mutate((data) => {
|
||||
let group = data.generationGroups.find((item) => item.id === job.groupId);
|
||||
if (!group) {
|
||||
const taskId = job.taskId ?? crypto.randomUUID();
|
||||
group = {
|
||||
id: job.groupId,
|
||||
userId: job.userId,
|
||||
providerId: job.providerId,
|
||||
privacy: "private",
|
||||
status: "queued",
|
||||
modelId: job.modelId,
|
||||
prompt: job.prompt,
|
||||
negativePrompt: job.negativePrompt,
|
||||
tasks: [
|
||||
{
|
||||
id: taskId,
|
||||
groupId: job.groupId,
|
||||
status: "queued",
|
||||
mode: job.mode,
|
||||
aspectRatio: "16:9",
|
||||
resolution: job.resolution,
|
||||
quality: "standard",
|
||||
attempt: 0,
|
||||
maxAttempts: 3,
|
||||
progress: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
groupId: job.groupId,
|
||||
status: "queued",
|
||||
mode: job.mode,
|
||||
aspectRatio: "9:16",
|
||||
resolution: job.resolution,
|
||||
quality: "standard",
|
||||
attempt: 0,
|
||||
maxAttempts: 3,
|
||||
progress: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
],
|
||||
assets: makeGenerationAssets({ "16:9": taskId, "9:16": undefined }, now),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
data.generationGroups.push(group);
|
||||
}
|
||||
|
||||
const task = findWorkerTask(group, job.taskId);
|
||||
if (task) {
|
||||
task.status = "queued";
|
||||
task.progress = 0;
|
||||
task.errorCode = undefined;
|
||||
task.errorMessage = undefined;
|
||||
task.updatedAt = now;
|
||||
}
|
||||
group.status = "queued";
|
||||
group.updatedAt = now;
|
||||
});
|
||||
}
|
||||
|
||||
async markGenerationStatus(
|
||||
groupId: string,
|
||||
taskId: string | undefined,
|
||||
groupStatus: GenerationGroupStatus,
|
||||
taskStatus: GenerationTaskStatus,
|
||||
progress: number
|
||||
): Promise<void> {
|
||||
const now = nowIso();
|
||||
await this.mutate((data) => {
|
||||
const group = requireWorkerGenerationGroup(data, groupId);
|
||||
const task = findWorkerTask(group, taskId);
|
||||
group.status = groupStatus;
|
||||
group.updatedAt = now;
|
||||
if (task) {
|
||||
task.status = taskStatus;
|
||||
task.progress = progress;
|
||||
task.updatedAt = now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async incrementGenerationAttempt(groupId: string, taskId?: string): Promise<number> {
|
||||
let attempt = 1;
|
||||
await this.mutate((data) => {
|
||||
const group = requireWorkerGenerationGroup(data, groupId);
|
||||
const task = findWorkerTask(group, taskId);
|
||||
if (task) {
|
||||
task.attempt += 1;
|
||||
task.updatedAt = nowIso();
|
||||
attempt = task.attempt;
|
||||
}
|
||||
});
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async markGenerationFailure(
|
||||
groupId: string,
|
||||
taskId: string | undefined,
|
||||
groupStatus: "failed" | "partial_succeeded",
|
||||
errorCode: string,
|
||||
errorMessage: string
|
||||
): Promise<void> {
|
||||
const now = nowIso();
|
||||
await this.mutate((data) => {
|
||||
const group = requireWorkerGenerationGroup(data, groupId);
|
||||
const task = findWorkerTask(group, taskId);
|
||||
group.status = groupStatus;
|
||||
group.updatedAt = now;
|
||||
if (task) {
|
||||
task.status = "failed";
|
||||
task.errorCode = errorCode;
|
||||
task.errorMessage = errorMessage;
|
||||
task.updatedAt = now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createProviderCallLog(input: Omit<ProviderCallLogRecord, "createdAt" | "updatedAt" | "status">): Promise<ProviderCallLogRecord> {
|
||||
const now = nowIso();
|
||||
const log: ProviderCallLogRecord = { ...input, status: "started", createdAt: now, updatedAt: now };
|
||||
await this.mutate((data) => {
|
||||
data.providerCallLogs.push(log);
|
||||
});
|
||||
return log;
|
||||
}
|
||||
|
||||
async completeProviderCallLog(
|
||||
id: string,
|
||||
patch: Pick<ProviderCallLogRecord, "status" | "latencyMs" | "usage" | "rawMetadata" | "errorCode" | "errorMessage">
|
||||
): Promise<void> {
|
||||
await this.mutate((data) => {
|
||||
const log = data.providerCallLogs.find((item) => item.id === id);
|
||||
if (!log) {
|
||||
throw new Error(`Provider call log not found: ${id}`);
|
||||
}
|
||||
Object.assign(log, patch, { updatedAt: nowIso() });
|
||||
});
|
||||
}
|
||||
|
||||
async upsertGeneratedAsset(input: StoredProviderAssetInput): Promise<GeneratedAsset> {
|
||||
const now = nowIso();
|
||||
let asset: GeneratedAsset | undefined;
|
||||
await this.mutate((data) => {
|
||||
const group = requireWorkerGenerationGroup(data, input.groupId);
|
||||
const existing = group.assets.find((item) => item.kind === input.assetKind);
|
||||
const metadata = {
|
||||
storageUrl: input.storageUrl,
|
||||
objectKey: input.objectKey,
|
||||
bucket: input.bucket,
|
||||
byteSize: input.byteSize,
|
||||
sha256: input.sha256,
|
||||
providerId: input.providerId,
|
||||
modelId: input.modelId,
|
||||
seed: input.seed
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
if (input.taskId !== undefined) existing.taskId = input.taskId;
|
||||
existing.status = input.status;
|
||||
existing.width = input.width;
|
||||
existing.height = input.height;
|
||||
existing.mimeType = input.mimeType;
|
||||
existing.publicUrl = input.publicUrl;
|
||||
Object.assign(existing as GeneratedAsset & { metadata?: unknown }, { metadata });
|
||||
asset = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
asset = {
|
||||
id: crypto.randomUUID(),
|
||||
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
||||
kind: input.assetKind,
|
||||
status: input.status,
|
||||
...(input.width === undefined ? {} : { width: input.width }),
|
||||
...(input.height === undefined ? {} : { height: input.height }),
|
||||
...(input.mimeType === undefined ? {} : { mimeType: input.mimeType }),
|
||||
...(input.publicUrl === undefined ? {} : { publicUrl: input.publicUrl }),
|
||||
createdAt: now
|
||||
};
|
||||
Object.assign(asset as GeneratedAsset & { metadata?: unknown }, { metadata });
|
||||
group.assets.push(asset);
|
||||
group.updatedAt = now;
|
||||
});
|
||||
return asset!;
|
||||
}
|
||||
|
||||
private async write(data: DataStoreShape): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const tmpPath = `${this.filePath}.${process.pid}.tmp`;
|
||||
await writeFile(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
await rename(tmpPath, this.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
export const toPublicUser = (user: UserRecord) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt
|
||||
});
|
||||
|
||||
export const hasAdminRole = (roles: JsonUserRole[]): boolean =>
|
||||
roles.includes("admin") || roles.includes("super_admin");
|
||||
|
||||
export const makeGenerationAssets = (
|
||||
taskIds: Record<"16:9" | "9:16", string | undefined>,
|
||||
now: string
|
||||
): GeneratedAsset[] => {
|
||||
const assets: GeneratedAsset[] = [{ id: crypto.randomUUID(), kind: "master", status: "temporary", createdAt: now }];
|
||||
if (taskIds["16:9"]) {
|
||||
assets.push({
|
||||
id: crypto.randomUUID(),
|
||||
taskId: taskIds["16:9"],
|
||||
kind: "landscape",
|
||||
status: "temporary",
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
createdAt: now
|
||||
});
|
||||
}
|
||||
if (taskIds["9:16"]) {
|
||||
assets.push({
|
||||
id: crypto.randomUUID(),
|
||||
taskId: taskIds["9:16"],
|
||||
kind: "portrait",
|
||||
status: "temporary",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
createdAt: now
|
||||
});
|
||||
}
|
||||
return assets;
|
||||
};
|
||||
|
||||
const requireWorkerGenerationGroup = (data: DataStoreShape, groupId: string): StoredGenerationGroup => {
|
||||
const group = data.generationGroups.find((item) => item.id === groupId);
|
||||
if (!group) throw new Error(`Generation group not found: ${groupId}`);
|
||||
return group;
|
||||
};
|
||||
const findWorkerTask = (group: StoredGenerationGroup, taskId?: string) => group.tasks.find((task) => task.id === taskId) ?? group.tasks[0];
|
||||
13
packages/db/tsconfig.json
Executable file
13
packages/db/tsconfig.json
Executable file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
11
packages/image-pipeline/package.json
Normal file
11
packages/image-pipeline/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@wallmuse/image-pipeline",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" } },
|
||||
"scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "build": "tsc -p tsconfig.json" },
|
||||
"dependencies": { "@wallmuse/shared": "workspace:*" }
|
||||
}
|
||||
7
packages/image-pipeline/src/index.ts
Normal file
7
packages/image-pipeline/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AssetKind } from "@wallmuse/shared";
|
||||
export interface StoredProviderImage { bucket: string; objectKey: string; storageUrl: string; publicUrl: string; sha256: string; byteSize: number; mimeType: string; width?: number; height?: number; }
|
||||
export interface StoreProviderAssetInput { userId: string; groupId: string; assetKind: Extract<AssetKind, "master" | "landscape" | "portrait">; source: { kind: "url" | "base64"; value: string; mimeType?: string; width?: number; height?: number } }
|
||||
export class LocalProviderAssetStore { constructor(private readonly rootDir = process.env.WALLMUSE_STORAGE_DIR ?? ".wallmuse-data/storage") {} async storeProviderAsset(input: StoreProviderAssetInput): Promise<StoredProviderImage> { const bytes = await this.readSource(input.source); const mimeType = input.source.mimeType ?? "image/png"; const extension = mimeType.includes("jpeg") ? "jpg" : mimeType.includes("webp") ? "webp" : "png"; const objectKey = `users/${input.userId}/groups/${input.groupId}/${input.assetKind}/original.${extension}`; const targetPath = path.join(this.rootDir, "wallpaper-originals", objectKey); await mkdir(path.dirname(targetPath), { recursive: true }); await writeFile(targetPath, bytes); const stored: StoredProviderImage = { bucket: "wallpaper-originals", objectKey, storageUrl: `local://${path.resolve(targetPath)}`, publicUrl: `/storage/wallpaper-originals/${objectKey}`, sha256: createHash("sha256").update(bytes).digest("hex"), byteSize: bytes.byteLength, mimeType }; if (input.source.width !== undefined) stored.width = input.source.width; if (input.source.height !== undefined) stored.height = input.source.height; return stored; } private async readSource(source: StoreProviderAssetInput["source"]): Promise<Buffer> { if (source.kind === "base64") return Buffer.from(source.value, "base64"); if (source.value.startsWith("mock://")) return Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64"); const response = await fetch(source.value); if (!response.ok) throw new Error(`Failed to download provider URL: ${response.status} ${response.statusText}`); return Buffer.from(await response.arrayBuffer()); } }
|
||||
1
packages/image-pipeline/tsconfig.json
Normal file
1
packages/image-pipeline/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "declaration": true, "types": ["node"] }, "include": ["src/**/*.ts"] }
|
||||
25
packages/provider-adapters/package.json
Normal file
25
packages/provider-adapters/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wallmuse/provider-adapters",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wallmuse/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDefaultProviderRegistry,
|
||||
MockImageProviderAdapter,
|
||||
normalizeImageAssets,
|
||||
normalizeProviderError,
|
||||
OpenAICompatibleImageProviderAdapter,
|
||||
SiliconFlowImageProviderAdapter
|
||||
} from "../index";
|
||||
|
||||
describe("provider adapter registry", () => {
|
||||
it("creates implemented and reserved adapters", () => {
|
||||
const registry = createDefaultProviderRegistry();
|
||||
|
||||
expect(registry.mock.provider).toBe("mock");
|
||||
expect(registry["openai-compatible"].provider).toBe("openai-compatible");
|
||||
expect(registry.siliconflow.provider).toBe("siliconflow");
|
||||
expect(registry.dashscope.provider).toBe("dashscope");
|
||||
expect(registry.volcengine.provider).toBe("volcengine");
|
||||
expect(registry.zhipu.provider).toBe("zhipu");
|
||||
expect(registry["custom-template"].provider).toBe("custom-template");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mock provider", () => {
|
||||
it("returns deterministic URL assets with mapped dimensions", async () => {
|
||||
const adapter = new MockImageProviderAdapter();
|
||||
const result = await adapter.generateTextToImage(
|
||||
{
|
||||
model: "mock-wallpaper",
|
||||
prompt: "misty mountain wallpaper",
|
||||
size: { aspectRatio: "16:9", resolution: "2k" },
|
||||
seed: 42
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.assets[0]).toMatchObject({
|
||||
kind: "url",
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
seed: 42
|
||||
});
|
||||
expect(result.usage?.imageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("can return base64 assets for local tests", async () => {
|
||||
const adapter = new MockImageProviderAdapter();
|
||||
const result = await adapter.generateTextToImage(
|
||||
{
|
||||
model: "mock-wallpaper",
|
||||
prompt: "phone wallpaper",
|
||||
responseFormat: "base64",
|
||||
size: { aspectRatio: "9:16", resolution: "1k" }
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.assets[0]?.kind).toBe("base64");
|
||||
expect(result.assets[0]?.mimeType).toBe("image/png");
|
||||
expect(result.assets[0]?.width).toBe(720);
|
||||
expect(result.assets[0]?.height).toBe(1280);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI compatible provider", () => {
|
||||
it("posts to configurable image endpoint and normalizes base64 response", async () => {
|
||||
const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
|
||||
expect(_url).toBe("https://new-api.example.com/v1/images/generations");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
model: "gpt-image-1",
|
||||
prompt: "desktop wallpaper",
|
||||
n: 1,
|
||||
size: "1536x1024",
|
||||
response_format: "b64_json"
|
||||
});
|
||||
return jsonResponse({
|
||||
data: [{ b64_json: "data:image/png;base64,abc123" }],
|
||||
usage: { input_tokens: 12, output_tokens: 0 }
|
||||
});
|
||||
});
|
||||
const adapter = new OpenAICompatibleImageProviderAdapter();
|
||||
|
||||
const result = await adapter.generateTextToImage(
|
||||
{
|
||||
baseUrl: "https://new-api.example.com",
|
||||
model: "gpt-image-1",
|
||||
prompt: "desktop wallpaper",
|
||||
responseFormat: "base64",
|
||||
size: { aspectRatio: "16:9", resolution: "4k" }
|
||||
},
|
||||
{ auth: { apiKey: "test-key" }, fetch }
|
||||
);
|
||||
|
||||
expect(result.assets[0]).toMatchObject({
|
||||
kind: "base64",
|
||||
value: "abc123",
|
||||
mimeType: "image/png",
|
||||
width: 1536,
|
||||
height: 1024
|
||||
});
|
||||
expect(result.usage?.inputTokens).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiliconFlow provider", () => {
|
||||
it("uses image_size and normalizes URL response", async () => {
|
||||
const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
|
||||
expect(_url).toBe("https://api.siliconflow.cn/v1/images/generations");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
model: "black-forest-labs/FLUX.2-pro",
|
||||
prompt: "portrait wallpaper",
|
||||
image_size: "720x1280",
|
||||
batch_size: 1,
|
||||
seed: 7
|
||||
});
|
||||
return jsonResponse({ data: [{ url: "https://cdn.example.com/result.png" }] });
|
||||
});
|
||||
const adapter = new SiliconFlowImageProviderAdapter();
|
||||
|
||||
const result = await adapter.generateTextToImage(
|
||||
{
|
||||
model: "black-forest-labs/FLUX.2-pro",
|
||||
prompt: "portrait wallpaper",
|
||||
size: { aspectRatio: "9:16", resolution: "1k" },
|
||||
seed: 7
|
||||
},
|
||||
{ auth: { apiKey: "sf-key" }, fetch }
|
||||
);
|
||||
|
||||
expect(result.assets[0]).toMatchObject({
|
||||
kind: "url",
|
||||
value: "https://cdn.example.com/result.png",
|
||||
width: 720,
|
||||
height: 1280
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization helpers", () => {
|
||||
it("normalizes common provider errors", () => {
|
||||
expect(normalizeProviderError("siliconflow", { status: 429, message: "rate limit exceeded" })).toMatchObject({
|
||||
category: "rate_limit",
|
||||
retryable: true,
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(normalizeProviderError("openai-compatible", { status: 401, message: "invalid api key" })).toMatchObject({
|
||||
category: "authentication",
|
||||
retryable: false
|
||||
});
|
||||
|
||||
expect(normalizeProviderError("openai-compatible", { status: 504, message: "gateway timeout" })).toMatchObject({
|
||||
category: "timeout",
|
||||
retryable: true
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes URL and base64 image payloads", () => {
|
||||
expect(normalizeImageAssets({ data: [{ url: "https://example.com/a.png" }] })[0]).toMatchObject({
|
||||
kind: "url",
|
||||
value: "https://example.com/a.png"
|
||||
});
|
||||
|
||||
expect(normalizeImageAssets({ images: [{ image_base64: "data:image/jpeg;base64,zzz" }] })[0]).toMatchObject({
|
||||
kind: "base64",
|
||||
value: "zzz",
|
||||
mimeType: "image/jpeg"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
165
packages/provider-adapters/src/adapters/base-http-adapter.ts
Normal file
165
packages/provider-adapters/src/adapters/base-http-adapter.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { normalizeImageAssets, normalizeUsage } from "../asset-normalizer";
|
||||
import { normalizeProviderError, providerHttpError } from "../errors";
|
||||
import { resolveModelCapability, resolveSize } from "../capabilities/presets";
|
||||
import type {
|
||||
ImageGenerationResult,
|
||||
ImageProviderAdapter,
|
||||
ProviderCallContext,
|
||||
ProviderCode,
|
||||
ProviderEndpointConfig,
|
||||
ProviderError,
|
||||
TestConnectionInput,
|
||||
TestConnectionResult,
|
||||
TextToImageInput
|
||||
} from "../types";
|
||||
import { joinUrl } from "../url";
|
||||
|
||||
export abstract class BaseHttpImageAdapter implements ImageProviderAdapter {
|
||||
abstract readonly provider: ProviderCode;
|
||||
abstract readonly capabilities: ImageProviderAdapter["capabilities"];
|
||||
|
||||
protected abstract readonly defaultBaseUrl: string;
|
||||
protected abstract readonly defaultEndpointPath: string;
|
||||
|
||||
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
|
||||
const startedAt = Date.now();
|
||||
const fetchImpl = input.fetch ?? globalThis.fetch;
|
||||
|
||||
try {
|
||||
if (!fetchImpl) {
|
||||
throw new Error("No fetch implementation is available");
|
||||
}
|
||||
|
||||
const response = await fetchImpl(this.resolveUrl(input), {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(input.auth ?? {}, input.defaultHeaders),
|
||||
body: JSON.stringify({
|
||||
model: input.model ?? Object.keys(this.capabilities)[0] ?? "default",
|
||||
prompt: "WallMuse provider connection test",
|
||||
n: 1,
|
||||
...this.testPayloadExtras()
|
||||
}),
|
||||
...withSignal(abortSignal(input.timeoutMs))
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
provider: this.provider,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
...withModel(input.model),
|
||||
error: providerHttpError(this.provider, response.status, await safeJson(response))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
provider: this.provider,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
...withModel(input.model),
|
||||
rawMetadata: await safeJson(response)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
provider: this.provider,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
...withModel(input.model),
|
||||
error: this.normalizeError(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async generateTextToImage(input: TextToImageInput, context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||
const fetchImpl = context.fetch ?? globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
throw new Error("No fetch implementation is available");
|
||||
}
|
||||
|
||||
const capability = resolveModelCapability(this.capabilities, input.model, input.capability);
|
||||
const size = resolveSize(capability, input.size);
|
||||
const response = await fetchImpl(this.resolveUrl(input), {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(context.auth ?? {}, input.defaultHeaders),
|
||||
body: JSON.stringify(this.toRequestBody(input, size.providerSizeValue ?? `${size.width}x${size.height}`)),
|
||||
...withSignal(context.signal ?? abortSignal(context.timeoutMs))
|
||||
});
|
||||
|
||||
const body = await safeJson(response);
|
||||
if (!response.ok) {
|
||||
throw providerHttpError(this.provider, response.status, body);
|
||||
}
|
||||
|
||||
const assets = normalizeImageAssets(body, { width: input.width ?? size.width, height: input.height ?? size.height });
|
||||
return {
|
||||
provider: this.provider,
|
||||
model: input.model,
|
||||
assets,
|
||||
usage: normalizeUsage(body, assets.length),
|
||||
rawMetadata: body
|
||||
};
|
||||
}
|
||||
|
||||
normalizeError(error: unknown): ProviderError {
|
||||
return normalizeProviderError(this.provider, error);
|
||||
}
|
||||
|
||||
protected resolveUrl(config: ProviderEndpointConfig): string {
|
||||
return joinUrl(config.baseUrl ?? this.defaultBaseUrl, config.endpointPath ?? this.defaultEndpointPath);
|
||||
}
|
||||
|
||||
protected buildHeaders(auth: { apiKey?: string; bearerToken?: string; headers?: Record<string, string> }, defaultHeaders?: Record<string, string>): Record<string, string> {
|
||||
const token = auth.bearerToken ?? auth.apiKey;
|
||||
return {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...defaultHeaders,
|
||||
...auth.headers
|
||||
};
|
||||
}
|
||||
|
||||
protected testPayloadExtras(): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: input.model,
|
||||
prompt: input.prompt,
|
||||
n: input.count ?? 1,
|
||||
size: providerSizeValue,
|
||||
...input.params
|
||||
};
|
||||
|
||||
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
|
||||
if (input.seed !== undefined) body.seed = input.seed;
|
||||
if (input.responseFormat) {
|
||||
body.response_format = input.responseFormat === "base64" ? "b64_json" : input.responseFormat;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeJson(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
function abortSignal(timeoutMs?: number): AbortSignal | undefined {
|
||||
if (!timeoutMs) return undefined;
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
|
||||
function withSignal(signal: AbortSignal | undefined): Pick<RequestInit, "signal"> {
|
||||
return signal ? { signal } : {};
|
||||
}
|
||||
|
||||
function withModel(model: string | undefined): Pick<TestConnectionResult, "model"> {
|
||||
return model ? { model } : {};
|
||||
}
|
||||
104
packages/provider-adapters/src/asset-normalizer.ts
Normal file
104
packages/provider-adapters/src/asset-normalizer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { GeneratedImageAsset, ProviderUsage } from "./types";
|
||||
|
||||
type ProviderImageRecord = Record<string, unknown>;
|
||||
|
||||
export function normalizeImageAssets(payload: unknown, fallbackSize?: { width?: number; height?: number }): GeneratedImageAsset[] {
|
||||
const data = getImageRecords(payload);
|
||||
|
||||
return data.map((item) => {
|
||||
const url = pickString(item, ["url", "image_url", "imageUrl", "uri"]);
|
||||
const b64 = pickString(item, ["b64_json", "base64", "image_base64", "image"]);
|
||||
const width = pickNumber(item, ["width"]) ?? fallbackSize?.width;
|
||||
const height = pickNumber(item, ["height"]) ?? fallbackSize?.height;
|
||||
const seed = pickNumber(item, ["seed"]);
|
||||
const mimeType = pickString(item, ["mime_type", "mimeType"]);
|
||||
|
||||
if (url) {
|
||||
return {
|
||||
kind: "url",
|
||||
value: url,
|
||||
...withOptionalAssetFields({ width, height, seed, mimeType })
|
||||
};
|
||||
}
|
||||
if (b64) {
|
||||
return {
|
||||
kind: "base64",
|
||||
value: stripDataUrlPrefix(b64),
|
||||
...withOptionalAssetFields({ width, height, seed, mimeType: mimeType ?? mimeFromDataUrl(b64) })
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Provider response did not include a URL or base64 image asset");
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeUsage(payload: unknown, imageCount: number): ProviderUsage {
|
||||
if (!payload || typeof payload !== "object") return { imageCount };
|
||||
const record = payload as Record<string, unknown>;
|
||||
const usage = record.usage && typeof record.usage === "object" ? (record.usage as Record<string, unknown>) : record;
|
||||
|
||||
return {
|
||||
imageCount,
|
||||
...withOptionalUsageFields({
|
||||
inputTokens: pickNumber(usage, ["input_tokens", "prompt_tokens", "inputTokens"]),
|
||||
outputTokens: pickNumber(usage, ["output_tokens", "completion_tokens", "outputTokens"]),
|
||||
providerCost: pickNumber(usage, ["cost", "providerCost"])
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function getImageRecords(payload: unknown): ProviderImageRecord[] {
|
||||
if (!payload || typeof payload !== "object") return [];
|
||||
const record = payload as Record<string, unknown>;
|
||||
const data = record.data ?? record.images ?? record.output;
|
||||
if (Array.isArray(data)) return data.filter((item): item is ProviderImageRecord => !!item && typeof item === "object");
|
||||
if (record.url || record.b64_json || record.base64 || record.image) return [record];
|
||||
return [];
|
||||
}
|
||||
|
||||
function pickString(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stripDataUrlPrefix(value: string): string {
|
||||
const match = value.match(/^data:[^;]+;base64,(.*)$/);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
function mimeFromDataUrl(value: string): string | undefined {
|
||||
const match = value.match(/^data:([^;]+);base64,/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
type OptionalAssetFields = {
|
||||
width?: number | undefined;
|
||||
height?: number | undefined;
|
||||
seed?: number | undefined;
|
||||
mimeType?: string | undefined;
|
||||
};
|
||||
|
||||
function withOptionalAssetFields(fields: OptionalAssetFields): Omit<GeneratedImageAsset, "kind" | "value"> {
|
||||
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Omit<GeneratedImageAsset, "kind" | "value">;
|
||||
}
|
||||
|
||||
type OptionalUsageFields = {
|
||||
inputTokens?: number | undefined;
|
||||
outputTokens?: number | undefined;
|
||||
providerCost?: number | undefined;
|
||||
};
|
||||
|
||||
function withOptionalUsageFields(fields: OptionalUsageFields): Omit<ProviderUsage, "imageCount"> {
|
||||
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Omit<ProviderUsage, "imageCount">;
|
||||
}
|
||||
183
packages/provider-adapters/src/capabilities/presets.ts
Normal file
183
packages/provider-adapters/src/capabilities/presets.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { AspectRatio, ImageSizePreset, ModelCapability, ResolutionTier } from "@wallmuse/shared";
|
||||
import type { ProviderSizeRequest } from "../types";
|
||||
|
||||
const WALLPAPER_PRESETS: ImageSizePreset[] = [
|
||||
{ aspectRatio: "16:9", resolution: "1k", width: 1280, height: 720, providerSizeValue: "1280x720", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "9:16", resolution: "1k", width: 720, height: 1280, providerSizeValue: "720x1280", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "16:9", resolution: "2k", width: 2560, height: 1440, providerSizeValue: "2560x1440", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "9:16", resolution: "2k", width: 1440, height: 2560, providerSizeValue: "1440x2560", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "16:9", resolution: "4k", width: 3840, height: 2160, providerSizeValue: "3840x2160", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "9:16", resolution: "4k", width: 2160, height: 3840, providerSizeValue: "2160x3840", native: true, requiresUpscale: false }
|
||||
];
|
||||
|
||||
const OPENAI_IMAGE_PRESETS: ImageSizePreset[] = [
|
||||
{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "9:16", resolution: "1k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "16:9", resolution: "1k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: true, requiresUpscale: false },
|
||||
{ aspectRatio: "16:9", resolution: "2k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: false, requiresUpscale: true },
|
||||
{ aspectRatio: "9:16", resolution: "2k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: false, requiresUpscale: true },
|
||||
{ aspectRatio: "16:9", resolution: "4k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: false, requiresUpscale: true },
|
||||
{ aspectRatio: "9:16", resolution: "4k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: false, requiresUpscale: true }
|
||||
];
|
||||
|
||||
const SILICONFLOW_PRESETS: ImageSizePreset[] = [
|
||||
...WALLPAPER_PRESETS.filter((preset) => preset.resolution !== "4k"),
|
||||
{ aspectRatio: "16:9", resolution: "4k", width: 2560, height: 1440, providerSizeValue: "2560x1440", native: false, requiresUpscale: true },
|
||||
{ aspectRatio: "9:16", resolution: "4k", width: 1440, height: 2560, providerSizeValue: "1440x2560", native: false, requiresUpscale: true },
|
||||
{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false }
|
||||
];
|
||||
|
||||
export const mockModelCapabilities: Record<string, ModelCapability> = {
|
||||
"mock-wallpaper": capability({
|
||||
supportsTextToImage: true,
|
||||
supportsImageToImage: true,
|
||||
supportsEdit: true,
|
||||
supportsNegativePrompt: true,
|
||||
supportsSeed: true,
|
||||
supportsBatch: true,
|
||||
supportsBase64Result: true,
|
||||
supportsUrlResult: true,
|
||||
supportsNative4k: true,
|
||||
maxBatchSize: 8,
|
||||
maxInputImages: 3,
|
||||
maxPixels: 8294400,
|
||||
sizePresets: [...WALLPAPER_PRESETS, { aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false }]
|
||||
})
|
||||
};
|
||||
|
||||
export const openAiCompatibleModelCapabilities: Record<string, ModelCapability> = {
|
||||
"gpt-image-1": capability({
|
||||
supportsTextToImage: true,
|
||||
supportsImageToImage: true,
|
||||
supportsEdit: true,
|
||||
supportsNegativePrompt: false,
|
||||
supportsSeed: false,
|
||||
supportsBatch: true,
|
||||
supportsBase64Result: true,
|
||||
supportsUrlResult: true,
|
||||
supportsNative4k: false,
|
||||
maxBatchSize: 4,
|
||||
maxInputImages: 3,
|
||||
maxPixels: 1572864,
|
||||
sizePresets: OPENAI_IMAGE_PRESETS
|
||||
}),
|
||||
default: capability({
|
||||
supportsTextToImage: true,
|
||||
supportsImageToImage: false,
|
||||
supportsEdit: false,
|
||||
supportsNegativePrompt: true,
|
||||
supportsSeed: true,
|
||||
supportsBatch: true,
|
||||
supportsBase64Result: true,
|
||||
supportsUrlResult: true,
|
||||
supportsNative4k: false,
|
||||
maxBatchSize: 4,
|
||||
maxPixels: 2097152,
|
||||
sizePresets: SILICONFLOW_PRESETS
|
||||
})
|
||||
};
|
||||
|
||||
export const siliconFlowModelCapabilities: Record<string, ModelCapability> = {
|
||||
"black-forest-labs/FLUX.2-pro": capability({
|
||||
supportsTextToImage: true,
|
||||
supportsImageToImage: false,
|
||||
supportsEdit: false,
|
||||
supportsNegativePrompt: true,
|
||||
supportsSeed: true,
|
||||
supportsBatch: false,
|
||||
supportsBase64Result: false,
|
||||
supportsUrlResult: true,
|
||||
supportsNative4k: false,
|
||||
maxBatchSize: 1,
|
||||
maxPixels: 3686400,
|
||||
sizePresets: SILICONFLOW_PRESETS
|
||||
}),
|
||||
"Qwen/Qwen-Image": capability({
|
||||
supportsTextToImage: true,
|
||||
supportsImageToImage: false,
|
||||
supportsEdit: false,
|
||||
supportsNegativePrompt: true,
|
||||
supportsSeed: true,
|
||||
supportsBatch: false,
|
||||
supportsBase64Result: false,
|
||||
supportsUrlResult: true,
|
||||
supportsNative4k: false,
|
||||
maxBatchSize: 1,
|
||||
maxPixels: 4194304,
|
||||
sizePresets: SILICONFLOW_PRESETS
|
||||
})
|
||||
};
|
||||
|
||||
export const placeholderCapabilities: Record<string, ModelCapability> = {
|
||||
default: capability({
|
||||
supportsTextToImage: false,
|
||||
supportsImageToImage: false,
|
||||
supportsEdit: false,
|
||||
supportsNegativePrompt: false,
|
||||
supportsSeed: false,
|
||||
supportsBatch: false,
|
||||
supportsBase64Result: false,
|
||||
supportsUrlResult: false,
|
||||
supportsNative4k: false,
|
||||
maxBatchSize: 1,
|
||||
maxPixels: 1048576,
|
||||
sizePresets: [{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, native: false, requiresUpscale: false }]
|
||||
})
|
||||
};
|
||||
|
||||
export function resolveModelCapability(
|
||||
capabilities: Record<string, ModelCapability>,
|
||||
model: string,
|
||||
override?: ModelCapability
|
||||
): ModelCapability {
|
||||
return override ?? capabilities[model] ?? capabilities.default ?? Object.values(capabilities)[0] ?? placeholderCapabilities.default!;
|
||||
}
|
||||
|
||||
export function resolveSize(
|
||||
capability: ModelCapability,
|
||||
request?: ProviderSizeRequest
|
||||
): ImageSizePreset {
|
||||
const aspectRatio = request?.aspectRatio ?? "1:1";
|
||||
const resolution = request?.resolution ?? "1k";
|
||||
const exact = capability.sizePresets.find((preset) => matchesPreset(preset, aspectRatio, resolution));
|
||||
if (exact) return exact;
|
||||
|
||||
const sameAspect = capability.sizePresets.find((preset) => preset.aspectRatio === aspectRatio);
|
||||
if (sameAspect) return sameAspect;
|
||||
|
||||
return capability.sizePresets[0] ?? placeholderCapabilities.default!.sizePresets[0]!;
|
||||
}
|
||||
|
||||
type CapabilityInput = Omit<
|
||||
ModelCapability,
|
||||
| "supportedAspectRatios"
|
||||
| "supportedResolutions"
|
||||
| "defaultParams"
|
||||
| "maxPromptLength"
|
||||
| "maxNegativePromptLength"
|
||||
| "supportsStreaming"
|
||||
| "maxInputImages"
|
||||
> &
|
||||
Partial<Pick<ModelCapability, "maxPromptLength" | "maxNegativePromptLength" | "defaultParams" | "supportsStreaming" | "maxInputImages">>;
|
||||
|
||||
function capability(input: CapabilityInput): ModelCapability {
|
||||
const sizePresets = input.sizePresets;
|
||||
return {
|
||||
...input,
|
||||
supportsStreaming: input.supportsStreaming ?? false,
|
||||
maxInputImages: input.maxInputImages ?? 0,
|
||||
maxPromptLength: input.maxPromptLength ?? 4000,
|
||||
maxNegativePromptLength: input.maxNegativePromptLength ?? 2000,
|
||||
supportedAspectRatios: unique(sizePresets.map((preset) => preset.aspectRatio)),
|
||||
supportedResolutions: unique(sizePresets.map((preset) => preset.resolution)),
|
||||
defaultParams: input.defaultParams ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
function matchesPreset(preset: ImageSizePreset, aspectRatio: AspectRatio, resolution: ResolutionTier): boolean {
|
||||
return preset.aspectRatio === aspectRatio && preset.resolution === resolution;
|
||||
}
|
||||
|
||||
function unique<T extends string>(values: T[]): T[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
137
packages/provider-adapters/src/errors.ts
Normal file
137
packages/provider-adapters/src/errors.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { ProviderCode, ProviderError, ProviderErrorCategory } from "./types";
|
||||
|
||||
type ErrorLike = {
|
||||
name?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
};
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
export class NormalizedProviderError extends Error {
|
||||
readonly providerError: ProviderError;
|
||||
|
||||
constructor(providerError: ProviderError) {
|
||||
super(providerError.message);
|
||||
this.name = "NormalizedProviderError";
|
||||
this.providerError = providerError;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProviderError(provider: ProviderCode | string, error: unknown): ProviderError {
|
||||
if (error instanceof NormalizedProviderError) {
|
||||
return error.providerError;
|
||||
}
|
||||
|
||||
const statusCode = getStatusCode(error);
|
||||
const providerCode = getProviderErrorCode(error, statusCode);
|
||||
const message = getProviderErrorMessage(error);
|
||||
const category = categorizeProviderError(statusCode, providerCode, message);
|
||||
|
||||
return {
|
||||
provider,
|
||||
category,
|
||||
code: providerCode,
|
||||
message,
|
||||
retryable: isRetryable(category, statusCode),
|
||||
...(statusCode !== undefined ? { statusCode } : {}),
|
||||
raw: error
|
||||
};
|
||||
}
|
||||
|
||||
export function providerHttpError(provider: ProviderCode | string, statusCode: number, body: unknown): ProviderError {
|
||||
const code = getBodyErrorCode(body) ?? `HTTP_${statusCode}`;
|
||||
const message = getBodyErrorMessage(body) ?? `Provider request failed with HTTP ${statusCode}`;
|
||||
const category = categorizeProviderError(statusCode, code, message);
|
||||
|
||||
return {
|
||||
provider,
|
||||
category,
|
||||
code,
|
||||
message,
|
||||
statusCode,
|
||||
retryable: isRetryable(category, statusCode),
|
||||
raw: body
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusCode(error: unknown): number | undefined {
|
||||
const value = error as ErrorLike;
|
||||
return value?.statusCode ?? value?.status ?? value?.response?.status;
|
||||
}
|
||||
|
||||
function getProviderErrorCode(error: unknown, statusCode?: number): string {
|
||||
const value = error as ErrorLike;
|
||||
const fromBody = getBodyErrorCode(value?.response?.data) ?? getBodyErrorCode(error);
|
||||
return fromBody ?? value?.code ?? (statusCode ? `HTTP_${statusCode}` : "PROVIDER_ERROR");
|
||||
}
|
||||
|
||||
function getProviderErrorMessage(error: unknown): string {
|
||||
const value = error as ErrorLike;
|
||||
return getBodyErrorMessage(value?.response?.data) ?? getBodyErrorMessage(error) ?? value?.message ?? "Provider request failed";
|
||||
}
|
||||
|
||||
function getBodyErrorCode(body: unknown): string | undefined {
|
||||
if (!body || typeof body !== "object") return undefined;
|
||||
const record = body as Record<string, unknown>;
|
||||
const nested = record.error && typeof record.error === "object" ? (record.error as Record<string, unknown>) : undefined;
|
||||
const code = nested?.code ?? nested?.type ?? record.code ?? record.error_code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
function getBodyErrorMessage(body: unknown): string | undefined {
|
||||
if (!body || typeof body !== "object") return undefined;
|
||||
const record = body as Record<string, unknown>;
|
||||
const nested = record.error && typeof record.error === "object" ? (record.error as Record<string, unknown>) : undefined;
|
||||
const message = nested?.message ?? record.message ?? record.error_msg ?? record.detail;
|
||||
return typeof message === "string" ? message : undefined;
|
||||
}
|
||||
|
||||
function categorizeProviderError(statusCode: number | undefined, code: string, message: string): ProviderErrorCategory {
|
||||
const normalized = `${code} ${message}`.toLowerCase();
|
||||
|
||||
if (statusCode === 401 || normalized.includes("invalid api key") || normalized.includes("unauthorized")) {
|
||||
return "authentication";
|
||||
}
|
||||
if (statusCode === 403 || normalized.includes("permission") || normalized.includes("forbidden")) {
|
||||
return normalized.includes("policy") || normalized.includes("safety") ? "content_policy" : "permission";
|
||||
}
|
||||
if (statusCode === 408 || normalized.includes("timeout") || normalized.includes("aborted")) {
|
||||
return "timeout";
|
||||
}
|
||||
if (statusCode === 429 || normalized.includes("rate limit") || normalized.includes("too many request")) {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (normalized.includes("quota") || normalized.includes("insufficient") || normalized.includes("balance")) {
|
||||
return "quota";
|
||||
}
|
||||
if (normalized.includes("content policy") || normalized.includes("safety") || normalized.includes("sensitive")) {
|
||||
return "content_policy";
|
||||
}
|
||||
if (statusCode === 400 || statusCode === 422) {
|
||||
return "bad_request";
|
||||
}
|
||||
if (statusCode === 404) {
|
||||
return "not_found";
|
||||
}
|
||||
if (statusCode && statusCode >= 500) {
|
||||
return "provider_unavailable";
|
||||
}
|
||||
if (normalized.includes("fetch failed") || normalized.includes("econn") || normalized.includes("network")) {
|
||||
return "network";
|
||||
}
|
||||
|
||||
return statusCode ? "provider_error" : "unknown";
|
||||
}
|
||||
|
||||
function isRetryable(category: ProviderErrorCategory, statusCode?: number): boolean {
|
||||
if (category === "rate_limit" || category === "timeout" || category === "network" || category === "provider_unavailable") {
|
||||
return true;
|
||||
}
|
||||
return typeof statusCode === "number" && statusCode >= 500;
|
||||
}
|
||||
16
packages/provider-adapters/src/index.ts
Normal file
16
packages/provider-adapters/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export * from "./types.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./asset-normalizer.js";
|
||||
export * from "./capabilities/presets.js";
|
||||
export * from "./providers/mock.js";
|
||||
export * from "./providers/openai-compatible.js";
|
||||
export * from "./providers/siliconflow.js";
|
||||
export * from "./providers/placeholders.js";
|
||||
export * from "./registry.js";
|
||||
|
||||
import { createImageProviderAdapter } from "./registry.js";
|
||||
import type { ImageProviderAdapter, ProviderCode } from "./types.js";
|
||||
|
||||
export function getImageProviderAdapter(provider: string): ImageProviderAdapter {
|
||||
return createImageProviderAdapter(provider as ProviderCode);
|
||||
}
|
||||
70
packages/provider-adapters/src/providers/mock.ts
Normal file
70
packages/provider-adapters/src/providers/mock.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { mockModelCapabilities, resolveModelCapability, resolveSize } from "../capabilities/presets";
|
||||
import type {
|
||||
ImageGenerationResult,
|
||||
ImageProviderAdapter,
|
||||
ImageToImageInput,
|
||||
ProviderCallContext,
|
||||
ProviderError,
|
||||
TestConnectionInput,
|
||||
TestConnectionResult,
|
||||
TextToImageInput
|
||||
} from "../types";
|
||||
import { normalizeProviderError } from "../errors";
|
||||
|
||||
const ONE_PIXEL_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
|
||||
export class MockImageProviderAdapter implements ImageProviderAdapter {
|
||||
readonly provider = "mock" as const;
|
||||
readonly capabilities = mockModelCapabilities;
|
||||
|
||||
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
|
||||
return {
|
||||
ok: true,
|
||||
provider: this.provider,
|
||||
model: input.model ?? "mock-wallpaper",
|
||||
latencyMs: 0,
|
||||
rawMetadata: { mode: "mock" }
|
||||
};
|
||||
}
|
||||
|
||||
async generateTextToImage(input: TextToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||
return this.generate(input);
|
||||
}
|
||||
|
||||
async generateImageToImage(input: ImageToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||
return this.generate(input);
|
||||
}
|
||||
|
||||
normalizeError(error: unknown): ProviderError {
|
||||
return normalizeProviderError(this.provider, error);
|
||||
}
|
||||
|
||||
private generate(input: TextToImageInput): ImageGenerationResult {
|
||||
const capability = resolveModelCapability(this.capabilities, input.model, input.capability);
|
||||
const size = resolveSize(capability, input.size);
|
||||
const count = input.count ?? 1;
|
||||
const responseAsBase64 = input.responseFormat === "base64" || input.responseFormat === "b64_json";
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
model: input.model,
|
||||
assets: Array.from({ length: count }, (_, index) =>
|
||||
({
|
||||
kind: responseAsBase64 ? "base64" : "url",
|
||||
value: responseAsBase64 ? ONE_PIXEL_PNG : `mock://wallmuse/${input.model}/${size.width}x${size.height}/${input.seed ?? "auto"}-${index}.png`,
|
||||
mimeType: "image/png",
|
||||
width: input.width ?? size.width,
|
||||
height: input.height ?? size.height,
|
||||
...(input.seed !== undefined ? { seed: input.seed } : {})
|
||||
})
|
||||
),
|
||||
usage: { imageCount: count },
|
||||
rawMetadata: {
|
||||
prompt: input.prompt,
|
||||
negativePrompt: input.negativePrompt,
|
||||
size
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseHttpImageAdapter } from "../adapters/base-http-adapter";
|
||||
import { openAiCompatibleModelCapabilities } from "../capabilities/presets";
|
||||
import type { TextToImageInput } from "../types";
|
||||
|
||||
export class OpenAICompatibleImageProviderAdapter extends BaseHttpImageAdapter {
|
||||
readonly provider = "openai-compatible" as const;
|
||||
readonly capabilities = openAiCompatibleModelCapabilities;
|
||||
protected readonly defaultBaseUrl = "https://api.openai.com";
|
||||
protected readonly defaultEndpointPath = "/v1/images/generations";
|
||||
|
||||
protected override toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
|
||||
const body = super.toRequestBody(input, providerSizeValue);
|
||||
|
||||
if (input.quality) {
|
||||
body.quality = input.quality === "ultra" ? "hd" : input.quality;
|
||||
}
|
||||
if (input.width && input.height) {
|
||||
body.size = `${input.width}x${input.height}`;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
50
packages/provider-adapters/src/providers/placeholders.ts
Normal file
50
packages/provider-adapters/src/providers/placeholders.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { placeholderCapabilities } from "../capabilities/presets";
|
||||
import { normalizeProviderError } from "../errors";
|
||||
import type {
|
||||
ImageGenerationResult,
|
||||
ImageProviderAdapter,
|
||||
ProviderCallContext,
|
||||
ProviderCode,
|
||||
ProviderError,
|
||||
TestConnectionInput,
|
||||
TestConnectionResult,
|
||||
TextToImageInput
|
||||
} from "../types";
|
||||
|
||||
export class ReservedImageProviderAdapter implements ImageProviderAdapter {
|
||||
readonly capabilities = placeholderCapabilities;
|
||||
|
||||
constructor(readonly provider: Extract<ProviderCode, "dashscope" | "volcengine" | "zhipu" | "custom-template">) {}
|
||||
|
||||
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
|
||||
return {
|
||||
ok: false,
|
||||
provider: this.provider,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
error: {
|
||||
provider: this.provider,
|
||||
category: "provider_error",
|
||||
code: "ADAPTER_RESERVED",
|
||||
message: `${this.provider} adapter is reserved but not implemented yet`,
|
||||
retryable: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async generateTextToImage(_input: TextToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||
throw this.normalizeError(new Error(`${this.provider} adapter is reserved but not implemented yet`));
|
||||
}
|
||||
|
||||
normalizeError(error: unknown): ProviderError {
|
||||
const normalized = normalizeProviderError(this.provider, error);
|
||||
if (normalized.code === "PROVIDER_ERROR") {
|
||||
return {
|
||||
...normalized,
|
||||
category: "provider_error",
|
||||
code: "ADAPTER_RESERVED",
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
29
packages/provider-adapters/src/providers/siliconflow.ts
Normal file
29
packages/provider-adapters/src/providers/siliconflow.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { BaseHttpImageAdapter } from "../adapters/base-http-adapter";
|
||||
import { siliconFlowModelCapabilities } from "../capabilities/presets";
|
||||
import type { TextToImageInput } from "../types";
|
||||
|
||||
export class SiliconFlowImageProviderAdapter extends BaseHttpImageAdapter {
|
||||
readonly provider = "siliconflow" as const;
|
||||
readonly capabilities = siliconFlowModelCapabilities;
|
||||
protected readonly defaultBaseUrl = "https://api.siliconflow.cn";
|
||||
protected readonly defaultEndpointPath = "/v1/images/generations";
|
||||
|
||||
protected override testPayloadExtras(): Record<string, unknown> {
|
||||
return { image_size: "1024x1024" };
|
||||
}
|
||||
|
||||
protected override toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: input.model,
|
||||
prompt: input.prompt,
|
||||
image_size: input.width && input.height ? `${input.width}x${input.height}` : providerSizeValue,
|
||||
batch_size: input.count ?? 1,
|
||||
...input.params
|
||||
};
|
||||
|
||||
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
|
||||
if (input.seed !== undefined) body.seed = input.seed;
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
33
packages/provider-adapters/src/registry.ts
Normal file
33
packages/provider-adapters/src/registry.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { MockImageProviderAdapter } from "./providers/mock";
|
||||
import { OpenAICompatibleImageProviderAdapter } from "./providers/openai-compatible";
|
||||
import { ReservedImageProviderAdapter } from "./providers/placeholders";
|
||||
import { SiliconFlowImageProviderAdapter } from "./providers/siliconflow";
|
||||
import type { ImageProviderAdapter, ProviderCode } from "./types";
|
||||
|
||||
export function createImageProviderAdapter(provider: ProviderCode): ImageProviderAdapter {
|
||||
switch (provider) {
|
||||
case "mock":
|
||||
return new MockImageProviderAdapter();
|
||||
case "openai-compatible":
|
||||
return new OpenAICompatibleImageProviderAdapter();
|
||||
case "siliconflow":
|
||||
return new SiliconFlowImageProviderAdapter();
|
||||
case "dashscope":
|
||||
case "volcengine":
|
||||
case "zhipu":
|
||||
case "custom-template":
|
||||
return new ReservedImageProviderAdapter(provider);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultProviderRegistry(): Record<ProviderCode, ImageProviderAdapter> {
|
||||
return {
|
||||
mock: createImageProviderAdapter("mock"),
|
||||
"openai-compatible": createImageProviderAdapter("openai-compatible"),
|
||||
siliconflow: createImageProviderAdapter("siliconflow"),
|
||||
dashscope: createImageProviderAdapter("dashscope"),
|
||||
volcengine: createImageProviderAdapter("volcengine"),
|
||||
zhipu: createImageProviderAdapter("zhipu"),
|
||||
"custom-template": createImageProviderAdapter("custom-template")
|
||||
};
|
||||
}
|
||||
144
packages/provider-adapters/src/types.ts
Normal file
144
packages/provider-adapters/src/types.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { AspectRatio, ModelCapability, ResolutionTier } from "@wallmuse/shared";
|
||||
|
||||
export type ProviderCode =
|
||||
| "mock"
|
||||
| "openai-compatible"
|
||||
| "siliconflow"
|
||||
| "dashscope"
|
||||
| "volcengine"
|
||||
| "zhipu"
|
||||
| "custom-template";
|
||||
|
||||
export type ProviderAuth = {
|
||||
apiKey?: string;
|
||||
bearerToken?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ProviderCallContext = {
|
||||
requestId?: string;
|
||||
timeoutMs?: number;
|
||||
auth?: ProviderAuth;
|
||||
signal?: AbortSignal;
|
||||
fetch?: typeof fetch;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProviderEndpointConfig = {
|
||||
baseUrl?: string;
|
||||
endpointPath?: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type TestConnectionInput = ProviderEndpointConfig & {
|
||||
model?: string;
|
||||
auth?: ProviderAuth;
|
||||
timeoutMs?: number;
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
|
||||
export type TestConnectionResult = {
|
||||
ok: boolean;
|
||||
provider: ProviderCode;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
error?: ProviderError;
|
||||
rawMetadata?: unknown;
|
||||
};
|
||||
|
||||
export type ImageReference = {
|
||||
kind: "url" | "base64";
|
||||
value: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
export type ProviderSizeRequest = {
|
||||
aspectRatio: AspectRatio;
|
||||
resolution: ResolutionTier;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export type TextToImageInput = ProviderEndpointConfig & {
|
||||
model: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
size?: ProviderSizeRequest;
|
||||
width?: number;
|
||||
height?: number;
|
||||
count?: number;
|
||||
seed?: number;
|
||||
quality?: "standard" | "hd" | "ultra";
|
||||
responseFormat?: "url" | "base64" | "b64_json";
|
||||
params?: Record<string, unknown>;
|
||||
capability?: ModelCapability;
|
||||
};
|
||||
|
||||
export type ImageToImageInput = TextToImageInput & {
|
||||
images: ImageReference[];
|
||||
strength?: number;
|
||||
};
|
||||
|
||||
export type GeneratedImageAsset = {
|
||||
kind: "url" | "base64";
|
||||
value: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
export type ProviderUsage = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
imageCount?: number;
|
||||
providerCost?: number;
|
||||
};
|
||||
|
||||
export type ImageGenerationResult = {
|
||||
provider: ProviderCode | string;
|
||||
model: string;
|
||||
assets: GeneratedImageAsset[];
|
||||
usage?: ProviderUsage;
|
||||
rawMetadata?: unknown;
|
||||
};
|
||||
|
||||
export type ProviderErrorCategory =
|
||||
| "authentication"
|
||||
| "permission"
|
||||
| "rate_limit"
|
||||
| "quota"
|
||||
| "timeout"
|
||||
| "bad_request"
|
||||
| "not_found"
|
||||
| "provider_unavailable"
|
||||
| "provider_error"
|
||||
| "content_policy"
|
||||
| "network"
|
||||
| "unknown";
|
||||
|
||||
export type ProviderError = {
|
||||
provider: ProviderCode | string;
|
||||
category: ProviderErrorCategory;
|
||||
code: string;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
retryable: boolean;
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
export interface ImageProviderAdapter {
|
||||
readonly provider: ProviderCode;
|
||||
readonly capabilities: Record<string, ModelCapability>;
|
||||
|
||||
testConnection(input: TestConnectionInput): Promise<TestConnectionResult>;
|
||||
|
||||
generateTextToImage(input: TextToImageInput, context: ProviderCallContext): Promise<ImageGenerationResult>;
|
||||
|
||||
generateImageToImage?(
|
||||
input: ImageToImageInput,
|
||||
context: ProviderCallContext
|
||||
): Promise<ImageGenerationResult>;
|
||||
|
||||
normalizeError(error: unknown): ProviderError;
|
||||
}
|
||||
14
packages/provider-adapters/src/url.ts
Normal file
14
packages/provider-adapters/src/url.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function joinUrl(baseUrl: string, endpointPath: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
const path = endpointPath.replace(/^\/+/, "");
|
||||
return `${base}/${path}`;
|
||||
}
|
||||
|
||||
export function withDefaultImageEndpoint(baseUrl: string | undefined, endpointPath = "/v1/images/generations"): string {
|
||||
return joinUrl(baseUrl ?? "https://api.openai.com", endpointPath);
|
||||
}
|
||||
|
||||
export function ensureV1BaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
||||
}
|
||||
10
packages/provider-adapters/tsconfig.json
Normal file
10
packages/provider-adapters/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
21
packages/shared/package.json
Executable file
21
packages/shared/package.json
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@wallmuse/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.4"
|
||||
}
|
||||
}
|
||||
9
packages/shared/src/api-paths.ts
Executable file
9
packages/shared/src/api-paths.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
export const API_V1_PREFIX = "/api/v1" as const;
|
||||
|
||||
export const apiPaths = {
|
||||
appConfig: `${API_V1_PREFIX}/app/config`,
|
||||
providers: `${API_V1_PREFIX}/providers`,
|
||||
models: `${API_V1_PREFIX}/models`,
|
||||
generations: `${API_V1_PREFIX}/generations`,
|
||||
generationGroup: (id: string) => `${API_V1_PREFIX}/generation-groups/${id}`
|
||||
} as const;
|
||||
101
packages/shared/src/dto/api-management.ts
Executable file
101
packages/shared/src/dto/api-management.ts
Executable file
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
import { ApiKeyModeSchema, ModelCapabilitySchema, ModelPricingSchema, ModelStatusSchema, ProviderStatusSchema } from "../model-capability";
|
||||
|
||||
export const UserRoleSchema = z.enum(["user", "admin", "super_admin"]);
|
||||
|
||||
export const PublicUserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
roles: z.array(UserRoleSchema),
|
||||
createdAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export const RegisterRequestSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string().min(8).max(128),
|
||||
name: z.string().min(1).max(80).optional()
|
||||
});
|
||||
|
||||
export const LoginRequestSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string().min(1).max(128)
|
||||
});
|
||||
|
||||
export const AuthResponseSchema = z.object({
|
||||
user: PublicUserSchema,
|
||||
token: z.string().min(1)
|
||||
});
|
||||
|
||||
export const CreateUserApiKeyRequestSchema = z.object({
|
||||
providerId: z.string().uuid(),
|
||||
name: z.string().min(1).max(80),
|
||||
apiKey: z.string().min(6).max(4096),
|
||||
baseUrl: z.string().url().optional(),
|
||||
defaultModelId: z.string().uuid().optional()
|
||||
});
|
||||
|
||||
export const UserApiKeyResponseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
providerId: z.string().uuid(),
|
||||
name: z.string(),
|
||||
maskedKey: z.string(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
defaultModelId: z.string().uuid().optional(),
|
||||
enabled: z.boolean(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export const CreateProviderRequestSchema = z.object({
|
||||
slug: z.string().min(2).max(80).regex(/^[a-z0-9][a-z0-9-_]*$/),
|
||||
displayName: z.string().min(1).max(120),
|
||||
baseUrl: z.string().url().optional(),
|
||||
authType: z.enum(["bearer", "api_key", "custom"]).default("bearer"),
|
||||
status: ProviderStatusSchema.default("healthy"),
|
||||
keyMode: ApiKeyModeSchema.default("hybrid"),
|
||||
supportsUserKeys: z.boolean().default(true),
|
||||
supportsPlatformKeys: z.boolean().default(true),
|
||||
healthCheckPath: z.string().max(255).optional()
|
||||
});
|
||||
|
||||
export const UpdateProviderRequestSchema = CreateProviderRequestSchema.partial().omit({ slug: true });
|
||||
|
||||
export const CreateModelRequestSchema = z.object({
|
||||
providerId: z.string().uuid(),
|
||||
slug: z.string().min(1).max(160),
|
||||
displayName: z.string().min(1).max(160),
|
||||
status: ModelStatusSchema.default("enabled"),
|
||||
keyMode: ApiKeyModeSchema.default("hybrid"),
|
||||
capability: ModelCapabilitySchema,
|
||||
pricing: ModelPricingSchema.optional(),
|
||||
sortOrder: z.number().int().default(0)
|
||||
});
|
||||
|
||||
export const UpdateModelRequestSchema = CreateModelRequestSchema.partial().omit({ providerId: true });
|
||||
|
||||
export const ProviderCallLogSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
taskId: z.string().uuid().optional(),
|
||||
providerId: z.string().uuid(),
|
||||
modelId: z.string().uuid().optional(),
|
||||
status: z.enum(["success", "failed"]),
|
||||
latencyMs: z.number().int().nonnegative().optional(),
|
||||
errorCode: z.string().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
createdAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export type UserRole = z.infer<typeof UserRoleSchema>;
|
||||
export type PublicUser = z.infer<typeof PublicUserSchema>;
|
||||
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
|
||||
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
||||
export type AuthResponse = z.infer<typeof AuthResponseSchema>;
|
||||
export type CreateUserApiKeyRequest = z.infer<typeof CreateUserApiKeyRequestSchema>;
|
||||
export type UserApiKeyResponse = z.infer<typeof UserApiKeyResponseSchema>;
|
||||
export type CreateProviderRequest = z.infer<typeof CreateProviderRequestSchema>;
|
||||
export type UpdateProviderRequest = z.infer<typeof UpdateProviderRequestSchema>;
|
||||
export type CreateModelRequest = z.infer<typeof CreateModelRequestSchema>;
|
||||
export type UpdateModelRequest = z.infer<typeof UpdateModelRequestSchema>;
|
||||
export type ProviderCallLog = z.infer<typeof ProviderCallLogSchema>;
|
||||
33
packages/shared/src/dto/app-config.ts
Executable file
33
packages/shared/src/dto/app-config.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { AspectRatioSchema, ModelSummarySchema, ProviderSummarySchema, ResolutionTierSchema } from "../model-capability";
|
||||
|
||||
export const AppFeatureFlagsSchema = z.object({
|
||||
authEnabled: z.boolean().default(true),
|
||||
galleryEnabled: z.boolean().default(true),
|
||||
userApiKeysEnabled: z.boolean().default(true),
|
||||
generationEnabled: z.boolean().default(true),
|
||||
darkModeEnabled: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export const AppConfigResponseSchema = z.object({
|
||||
site: z.object({
|
||||
name: z.string().default("WallMuse"),
|
||||
tagline: z.string().optional(),
|
||||
logoUrl: z.string().url().optional(),
|
||||
supportEmail: z.string().email().optional()
|
||||
}),
|
||||
generation: z.object({
|
||||
defaultModelId: z.string().uuid().optional(),
|
||||
defaultAspectRatios: z.array(AspectRatioSchema).min(1).default(["16:9", "9:16"]),
|
||||
defaultResolution: ResolutionTierSchema.default("2k"),
|
||||
maxBatchSize: z.number().int().positive().default(4),
|
||||
allowedResolutions: z.array(ResolutionTierSchema).min(1)
|
||||
}),
|
||||
features: AppFeatureFlagsSchema,
|
||||
providers: z.array(ProviderSummarySchema),
|
||||
models: z.array(ModelSummarySchema),
|
||||
updatedAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export type AppFeatureFlags = z.infer<typeof AppFeatureFlagsSchema>;
|
||||
export type AppConfigResponse = z.infer<typeof AppConfigResponseSchema>;
|
||||
84
packages/shared/src/dto/generation.ts
Executable file
84
packages/shared/src/dto/generation.ts
Executable file
@@ -0,0 +1,84 @@
|
||||
import { z } from "zod";
|
||||
import { AspectRatioSchema, GenerationModeSchema, GenerationQualitySchema, ResolutionTierSchema } from "../model-capability";
|
||||
import { GenerationGroupStatusSchema, GenerationTaskStatusSchema } from "../status";
|
||||
|
||||
export const AssetKindSchema = z.enum(["reference", "master", "landscape", "portrait", "thumbnail", "preview", "download_zip"]);
|
||||
export const AssetStatusSchema = z.enum(["temporary", "active", "deleted", "failed"]);
|
||||
export const ModerationStatusSchema = z.enum(["pending", "passed", "rejected", "manual_review"]);
|
||||
|
||||
export const CreateGenerationRequestSchema = z.object({
|
||||
mode: GenerationModeSchema,
|
||||
modelId: z.string().uuid(),
|
||||
prompt: z.string().min(1).max(4000),
|
||||
negativePrompt: z.string().max(2000).optional(),
|
||||
aspectRatios: z.array(AspectRatioSchema).min(1).max(3).default(["16:9", "9:16"]),
|
||||
resolution: ResolutionTierSchema.default("2k"),
|
||||
quality: GenerationQualitySchema.default("standard"),
|
||||
batchSize: z.number().int().min(1).max(8).default(1),
|
||||
seed: z.number().int().optional(),
|
||||
referenceAssetId: z.string().uuid().optional(),
|
||||
stylePresetId: z.string().uuid().optional(),
|
||||
userApiKeyId: z.string().uuid().optional(),
|
||||
publishToGallery: z.boolean().default(false),
|
||||
metadata: z.record(z.unknown()).default({})
|
||||
});
|
||||
|
||||
export const GenerationTaskSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
groupId: z.string().uuid(),
|
||||
status: GenerationTaskStatusSchema,
|
||||
mode: GenerationModeSchema,
|
||||
aspectRatio: AspectRatioSchema,
|
||||
resolution: ResolutionTierSchema,
|
||||
quality: GenerationQualitySchema,
|
||||
attempt: z.number().int().nonnegative(),
|
||||
maxAttempts: z.number().int().positive(),
|
||||
progress: z.number().int().min(0).max(100),
|
||||
errorCode: z.string().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export const GeneratedAssetSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
taskId: z.string().uuid().optional(),
|
||||
kind: AssetKindSchema,
|
||||
status: AssetStatusSchema,
|
||||
width: z.number().int().positive().optional(),
|
||||
height: z.number().int().positive().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
publicUrl: z.string().url().optional(),
|
||||
blurHash: z.string().optional(),
|
||||
createdAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export const GenerationGroupSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: GenerationGroupStatusSchema,
|
||||
modelId: z.string().uuid(),
|
||||
prompt: z.string(),
|
||||
negativePrompt: z.string().optional(),
|
||||
tasks: z.array(GenerationTaskSchema),
|
||||
assets: z.array(GeneratedAssetSchema).default([]),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export const CreateGenerationResponseSchema = z.object({
|
||||
generationGroup: GenerationGroupSchema,
|
||||
pollingUrl: z.string().min(1)
|
||||
});
|
||||
|
||||
export const GenerationGroupParamsSchema = z.object({
|
||||
id: z.string().uuid()
|
||||
});
|
||||
|
||||
export type AssetKind = z.infer<typeof AssetKindSchema>;
|
||||
export type AssetStatus = z.infer<typeof AssetStatusSchema>;
|
||||
export type ModerationStatus = z.infer<typeof ModerationStatusSchema>;
|
||||
export type CreateGenerationRequest = z.infer<typeof CreateGenerationRequestSchema>;
|
||||
export type GenerationTask = z.infer<typeof GenerationTaskSchema>;
|
||||
export type GeneratedAsset = z.infer<typeof GeneratedAssetSchema>;
|
||||
export type GenerationGroup = z.infer<typeof GenerationGroupSchema>;
|
||||
export type CreateGenerationResponse = z.infer<typeof CreateGenerationResponseSchema>;
|
||||
92
packages/shared/src/dto/web.ts
Executable file
92
packages/shared/src/dto/web.ts
Executable file
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod";
|
||||
import { AspectRatioSchema, ResolutionTierSchema } from "../model-capability";
|
||||
import { GenerationGroupStatusSchema } from "../status";
|
||||
|
||||
export const ThemePreferenceSchema = z.enum(["system", "light", "dark"]);
|
||||
|
||||
export const WebWallpaperSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
prompt: z.string(),
|
||||
imageUrl: z.string().url(),
|
||||
ratio: AspectRatioSchema,
|
||||
resolution: ResolutionTierSchema,
|
||||
style: z.string(),
|
||||
model: z.string(),
|
||||
likes: z.number().int().nonnegative(),
|
||||
downloads: z.number().int().nonnegative(),
|
||||
colors: z.array(z.string()),
|
||||
createdAt: z.string().datetime(),
|
||||
featured: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const WebGenerationAssetSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.enum(["Desktop", "Mobile", "Master"]),
|
||||
ratio: AspectRatioSchema,
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
imageUrl: z.string().url()
|
||||
});
|
||||
|
||||
export const WebGenerationGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
prompt: z.string(),
|
||||
negativePrompt: z.string().optional(),
|
||||
status: GenerationGroupStatusSchema,
|
||||
style: z.string(),
|
||||
model: z.string(),
|
||||
resolution: ResolutionTierSchema,
|
||||
consistencyScore: z.number().int().min(0).max(100),
|
||||
createdAt: z.string().datetime(),
|
||||
assets: z.array(WebGenerationAssetSchema)
|
||||
});
|
||||
|
||||
export const WebUserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
avatarInitials: z.string(),
|
||||
theme: ThemePreferenceSchema
|
||||
});
|
||||
|
||||
export const WebUserApiKeySchema = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
baseUrl: z.string(),
|
||||
model: z.string(),
|
||||
maskedKey: z.string(),
|
||||
isDefault: z.boolean(),
|
||||
status: z.enum(["untested", "connected", "failed"]),
|
||||
updatedAt: z.string().datetime()
|
||||
});
|
||||
|
||||
export const WebCreateGenerationInputSchema = z.object({
|
||||
mode: z.enum(["text_to_image", "image_to_image"]),
|
||||
prompt: z.string().min(1),
|
||||
negativePrompt: z.string().optional(),
|
||||
style: z.string(),
|
||||
resolution: ResolutionTierSchema,
|
||||
outputPair: z.boolean(),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
privateMode: z.boolean()
|
||||
});
|
||||
|
||||
export const WebSaveApiKeyInputSchema = z.object({
|
||||
provider: z.string(),
|
||||
baseUrl: z.string().url(),
|
||||
apiKey: z.string().min(1),
|
||||
model: z.string(),
|
||||
saveToAccount: z.boolean(),
|
||||
isDefault: z.boolean()
|
||||
});
|
||||
|
||||
export type ThemePreference = z.infer<typeof ThemePreferenceSchema>;
|
||||
export type WebWallpaper = z.infer<typeof WebWallpaperSchema>;
|
||||
export type WebGenerationAsset = z.infer<typeof WebGenerationAssetSchema>;
|
||||
export type WebGenerationGroup = z.infer<typeof WebGenerationGroupSchema>;
|
||||
export type WebUser = z.infer<typeof WebUserSchema>;
|
||||
export type WebUserApiKey = z.infer<typeof WebUserApiKeySchema>;
|
||||
export type WebCreateGenerationInput = z.infer<typeof WebCreateGenerationInputSchema>;
|
||||
export type WebSaveApiKeyInput = z.infer<typeof WebSaveApiKeyInputSchema>;
|
||||
7
packages/shared/src/index.ts
Executable file
7
packages/shared/src/index.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
export * from "./api-paths";
|
||||
export * from "./model-capability";
|
||||
export * from "./status";
|
||||
export * from "./dto/app-config";
|
||||
export * from "./dto/generation";
|
||||
export * from "./dto/api-management";
|
||||
export * from "./dto/web";
|
||||
84
packages/shared/src/model-capability.ts
Executable file
84
packages/shared/src/model-capability.ts
Executable file
@@ -0,0 +1,84 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const GenerationModeSchema = z.enum(["text_to_image", "image_to_image"]);
|
||||
export const GenerationQualitySchema = z.enum(["standard", "hd", "ultra"]);
|
||||
export const ResolutionTierSchema = z.enum(["1k", "2k", "4k"]);
|
||||
export const AspectRatioSchema = z.enum(["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]);
|
||||
export const ProviderStatusSchema = z.enum(["disabled", "healthy", "degraded", "error"]);
|
||||
export const ModelStatusSchema = z.enum(["draft", "enabled", "disabled", "deprecated"]);
|
||||
export const ApiKeyModeSchema = z.enum(["platform", "user_own", "hybrid"]);
|
||||
|
||||
export type GenerationMode = z.infer<typeof GenerationModeSchema>;
|
||||
export type GenerationQuality = z.infer<typeof GenerationQualitySchema>;
|
||||
export type ResolutionTier = z.infer<typeof ResolutionTierSchema>;
|
||||
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
export type ProviderStatus = z.infer<typeof ProviderStatusSchema>;
|
||||
export type ModelStatus = z.infer<typeof ModelStatusSchema>;
|
||||
export type ApiKeyMode = z.infer<typeof ApiKeyModeSchema>;
|
||||
|
||||
export const ImageSizePresetSchema = z.object({
|
||||
aspectRatio: AspectRatioSchema,
|
||||
resolution: ResolutionTierSchema,
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
providerSizeValue: z.string().optional(),
|
||||
native: z.boolean().default(true),
|
||||
requiresUpscale: z.boolean().default(false)
|
||||
});
|
||||
|
||||
export const ModelPricingSchema = z.object({
|
||||
currency: z.string().min(3).max(8).default("USD"),
|
||||
unit: z.enum(["image", "megapixel", "request", "credit"]),
|
||||
amount: z.number().nonnegative(),
|
||||
estimatedCredits: z.number().nonnegative().optional()
|
||||
});
|
||||
|
||||
export const ModelCapabilitySchema = z.object({
|
||||
supportsTextToImage: z.boolean(),
|
||||
supportsImageToImage: z.boolean(),
|
||||
supportsEdit: z.boolean().default(false),
|
||||
supportsNegativePrompt: z.boolean().default(false),
|
||||
supportsSeed: z.boolean().default(false),
|
||||
supportsBatch: z.boolean().default(false),
|
||||
supportsStreaming: z.boolean().default(false),
|
||||
supportsBase64Result: z.boolean().default(false),
|
||||
supportsUrlResult: z.boolean().default(true),
|
||||
supportsNative4k: z.boolean().default(false),
|
||||
maxBatchSize: z.number().int().positive().default(1),
|
||||
maxInputImages: z.number().int().nonnegative().default(0),
|
||||
maxPromptLength: z.number().int().positive().default(4000),
|
||||
maxNegativePromptLength: z.number().int().positive().default(2000),
|
||||
maxPixels: z.number().int().positive().optional(),
|
||||
supportedAspectRatios: z.array(AspectRatioSchema).min(1),
|
||||
supportedResolutions: z.array(ResolutionTierSchema).min(1),
|
||||
sizePresets: z.array(ImageSizePresetSchema).min(1),
|
||||
defaultParams: z.record(z.unknown()).default({})
|
||||
});
|
||||
|
||||
export type ImageSizePreset = z.infer<typeof ImageSizePresetSchema>;
|
||||
export type ModelPricing = z.infer<typeof ModelPricingSchema>;
|
||||
export type ModelCapability = z.infer<typeof ModelCapabilitySchema>;
|
||||
|
||||
export const ModelSummarySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
providerId: z.string().uuid(),
|
||||
slug: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
status: ModelStatusSchema,
|
||||
keyMode: ApiKeyModeSchema,
|
||||
capability: ModelCapabilitySchema,
|
||||
pricing: ModelPricingSchema.optional(),
|
||||
sortOrder: z.number().int().default(0)
|
||||
});
|
||||
|
||||
export const ProviderSummarySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
slug: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
status: ProviderStatusSchema,
|
||||
keyMode: ApiKeyModeSchema,
|
||||
modelCount: z.number().int().nonnegative().default(0)
|
||||
});
|
||||
|
||||
export type ModelSummary = z.infer<typeof ModelSummarySchema>;
|
||||
export type ProviderSummary = z.infer<typeof ProviderSummarySchema>;
|
||||
59
packages/shared/src/status.ts
Executable file
59
packages/shared/src/status.ts
Executable file
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const generationGroupStatuses = [
|
||||
"queued",
|
||||
"running",
|
||||
"partial_succeeded",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"canceled"
|
||||
] as const;
|
||||
|
||||
export const generationTaskStatuses = [
|
||||
"created",
|
||||
"queued",
|
||||
"dispatching",
|
||||
"running",
|
||||
"uploading",
|
||||
"post_processing",
|
||||
"moderating",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"retrying",
|
||||
"canceled",
|
||||
"expired"
|
||||
] as const;
|
||||
|
||||
export const terminalGenerationTaskStatuses = [
|
||||
"succeeded",
|
||||
"failed",
|
||||
"canceled",
|
||||
"expired"
|
||||
] as const;
|
||||
|
||||
export const retryableGenerationTaskStatuses = ["failed", "retrying"] as const;
|
||||
|
||||
export const GenerationGroupStatusSchema = z.enum(generationGroupStatuses);
|
||||
export const GenerationTaskStatusSchema = z.enum(generationTaskStatuses);
|
||||
|
||||
export type GenerationGroupStatus = z.infer<typeof GenerationGroupStatusSchema>;
|
||||
export type GenerationTaskStatus = z.infer<typeof GenerationTaskStatusSchema>;
|
||||
|
||||
export const generationTaskStateTransitions: Record<GenerationTaskStatus, readonly GenerationTaskStatus[]> = {
|
||||
created: ["queued", "canceled"],
|
||||
queued: ["dispatching", "canceled", "expired"],
|
||||
dispatching: ["running", "retrying", "failed", "canceled"],
|
||||
running: ["uploading", "retrying", "failed", "canceled"],
|
||||
uploading: ["post_processing", "retrying", "failed", "canceled"],
|
||||
post_processing: ["moderating", "retrying", "failed", "canceled"],
|
||||
moderating: ["succeeded", "failed", "canceled"],
|
||||
succeeded: [],
|
||||
failed: ["retrying"],
|
||||
retrying: ["queued", "failed", "canceled", "expired"],
|
||||
canceled: [],
|
||||
expired: []
|
||||
};
|
||||
|
||||
export function isTerminalGenerationTaskStatus(status: GenerationTaskStatus): boolean {
|
||||
return terminalGenerationTaskStatuses.includes(status as (typeof terminalGenerationTaskStatuses)[number]);
|
||||
}
|
||||
9
packages/shared/tsconfig.json
Executable file
9
packages/shared/tsconfig.json
Executable file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
14
packages/ui-tokens/package.json
Executable file
14
packages/ui-tokens/package.json
Executable file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@wallmuse/ui-tokens",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
7
packages/ui-tokens/src/index.ts
Executable file
7
packages/ui-tokens/src/index.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
export const breakpoints = {
|
||||
xs: 480,
|
||||
sm: 640,
|
||||
md: 900,
|
||||
lg: 1200,
|
||||
xl: 1600
|
||||
} as const;
|
||||
70
packages/ui-tokens/src/tokens.css
Executable file
70
packages/ui-tokens/src/tokens.css
Executable file
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--color-bg-page: #edf6ff;
|
||||
--color-bg-surface: rgba(255, 255, 255, 0.62);
|
||||
--color-bg-surface-strong: rgba(255, 255, 255, 0.82);
|
||||
--color-bg-control: rgba(255, 255, 255, 0.56);
|
||||
--color-bg-control-active: rgba(230, 240, 255, 0.86);
|
||||
--color-border-soft: rgba(120, 150, 190, 0.2);
|
||||
--color-border-strong: rgba(120, 160, 220, 0.36);
|
||||
--color-text-primary: #172235;
|
||||
--color-text-secondary: #617087;
|
||||
--color-text-muted: #97a4b7;
|
||||
--color-accent: #2f86ff;
|
||||
--color-accent-soft: #dceaff;
|
||||
--color-success: #20c997;
|
||||
--color-warning: #f7b955;
|
||||
--color-danger: #ff5c7a;
|
||||
--font-xs: 12px;
|
||||
--font-sm: 13px;
|
||||
--font-md: 14px;
|
||||
--font-lg: 16px;
|
||||
--font-xl: 20px;
|
||||
--font-2xl: 28px;
|
||||
--font-3xl: 40px;
|
||||
--radius-xs: 8px;
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 24px;
|
||||
--radius-xl: 36px;
|
||||
--radius-full: 999px;
|
||||
--shadow-shell: 0 28px 80px rgba(80, 130, 190, 0.18);
|
||||
--shadow-card: 0 14px 34px rgba(37, 60, 90, 0.16);
|
||||
--shadow-floating: 0 18px 42px rgba(40, 80, 130, 0.22);
|
||||
--glass-blur: blur(22px) saturate(140%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-bg-page: #07111f;
|
||||
--color-bg-surface: rgba(15, 27, 45, 0.68);
|
||||
--color-bg-surface-strong: rgba(18, 31, 52, 0.88);
|
||||
--color-bg-control: rgba(23, 38, 62, 0.72);
|
||||
--color-bg-control-active: rgba(35, 78, 135, 0.76);
|
||||
--color-border-soft: rgba(170, 205, 255, 0.14);
|
||||
--color-border-strong: rgba(95, 165, 255, 0.52);
|
||||
--color-text-primary: #eef6ff;
|
||||
--color-text-secondary: #a7b6ca;
|
||||
--color-text-muted: #687a92;
|
||||
--color-accent: #66aaff;
|
||||
--color-accent-soft: rgba(102, 170, 255, 0.18);
|
||||
--color-success: #35d8a6;
|
||||
--color-warning: #ffd166;
|
||||
--color-danger: #ff6b8b;
|
||||
--shadow-shell: 0 28px 80px rgba(0, 0, 0, 0.42);
|
||||
--shadow-card: 0 16px 38px rgba(0, 0, 0, 0.34);
|
||||
--shadow-floating: 0 20px 48px rgba(0, 0, 0, 0.46);
|
||||
--glass-blur: blur(24px) saturate(130%);
|
||||
}
|
||||
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
:root {
|
||||
--color-bg-surface: rgba(255, 255, 255, 0.92);
|
||||
--color-bg-surface-strong: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg-surface: rgba(15, 27, 45, 0.92);
|
||||
--color-bg-surface-strong: rgba(18, 31, 52, 0.96);
|
||||
}
|
||||
}
|
||||
13
packages/ui-tokens/tsconfig.json
Executable file
13
packages/ui-tokens/tsconfig.json
Executable file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user