Initial WallMuse project

This commit is contained in:
fenglee
2026-05-09 09:12:41 +00:00
commit 3ea7d29827
91 changed files with 13136 additions and 0 deletions

View 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
View 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";

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true
},
"include": [
"src/**/*.ts"
]
}

20
packages/db/package.json Executable file
View 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"
}
}

View 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
View File

@@ -0,0 +1 @@
export * from "./json-store";

523
packages/db/src/json-store.ts Executable file
View 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
View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"types": [
"node"
]
},
"include": [
"src/**/*.ts"
]
}

View 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:*" }
}

View 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()); } }

View File

@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "declaration": true, "types": ["node"] }, "include": ["src/**/*.ts"] }

View 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"
}
}

View File

@@ -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" }
});
}

View 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 } : {};
}

View 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">;
}

View 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));
}

View 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;
}

View 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);
}

View 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
}
};
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View 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")
};
}

View 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;
}

View 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`;
}

View 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
View 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"
}
}

View 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;

View 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>;

View 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>;

View 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
View 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
View 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";

View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,7 @@
export const breakpoints = {
xs: 480,
sm: 640,
md: 900,
lg: 1200,
xl: 1600
} as const;

View 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);
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"emitDeclarationOnly": false,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}