Initial WallMuse project
This commit is contained in:
5
.env.example
Executable file
5
.env.example
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=4000
|
||||||
|
JWT_SECRET=change-me-in-production
|
||||||
|
API_KEY_ENCRYPTION_SECRET=replace-with-32-byte-secret
|
||||||
|
WALLMUSE_DATA_DIR=/root/wallmuse/app/.data
|
||||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Environment and local secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
.data/
|
||||||
|
.wallmuse-data/
|
||||||
|
reports/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# TypeScript incremental build info
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Generated TypeScript output in source folders
|
||||||
|
packages/**/src/**/*.js
|
||||||
|
packages/**/src/**/*.d.ts
|
||||||
|
apps/**/src/**/*.js
|
||||||
|
apps/**/src/**/*.d.ts
|
||||||
12
apps/admin/index.html
Executable file
12
apps/admin/index.html
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WallMuse Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
apps/admin/package.json
Executable file
25
apps/admin/package.json
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@wallmuse/api-client": "workspace:*",
|
||||||
|
"vite": "^6.1.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"lucide-react": "^0.468.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
727
apps/admin/src/main.tsx
Executable file
727
apps/admin/src/main.tsx
Executable file
@@ -0,0 +1,727 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Aperture,
|
||||||
|
Bell,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
Database,
|
||||||
|
Gauge,
|
||||||
|
Image,
|
||||||
|
Lock,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
RefreshCcw,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
Users,
|
||||||
|
XCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
WallMuseApiClient,
|
||||||
|
type AdminDashboard,
|
||||||
|
type AdminModel,
|
||||||
|
type AdminProvider,
|
||||||
|
type AdminTask,
|
||||||
|
type AdminUser,
|
||||||
|
type GalleryReviewItem,
|
||||||
|
type PublicUser,
|
||||||
|
type SystemSettings
|
||||||
|
} from "@wallmuse/api-client";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
type ViewKey = "dashboard" | "users" | "providers" | "models" | "tasks" | "gallery" | "settings";
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_WALLMUSE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
const navItems: Array<{ key: ViewKey; label: string; icon: React.ComponentType<{ size?: number }> }> = [
|
||||||
|
{ key: "dashboard", label: "仪表盘", icon: Gauge },
|
||||||
|
{ key: "users", label: "用户管理", icon: Users },
|
||||||
|
{ key: "providers", label: "供应商管理", icon: Database },
|
||||||
|
{ key: "models", label: "模型管理", icon: Aperture },
|
||||||
|
{ key: "tasks", label: "任务管理", icon: Activity },
|
||||||
|
{ key: "gallery", label: "图库审核", icon: Image },
|
||||||
|
{ key: "settings", label: "系统设置", icon: Settings }
|
||||||
|
];
|
||||||
|
|
||||||
|
const createClient = (token?: string) =>
|
||||||
|
new WallMuseApiClient({
|
||||||
|
baseUrl: apiBaseUrl,
|
||||||
|
accessToken: token,
|
||||||
|
mockFallback: true
|
||||||
|
});
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem("wallmuse_admin_token") ?? "");
|
||||||
|
const [currentUser, setCurrentUser] = useState<PublicUser | null>(null);
|
||||||
|
const [view, setView] = useState<ViewKey>("dashboard");
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem("wallmuse_admin_theme") as Theme) || "light");
|
||||||
|
const [loadingUser, setLoadingUser] = useState(Boolean(token));
|
||||||
|
|
||||||
|
const client = useMemo(() => createClient(token), [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
localStorage.setItem("wallmuse_admin_theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setLoadingUser(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client
|
||||||
|
.getCurrentUser()
|
||||||
|
.then(setCurrentUser)
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("wallmuse_admin_token");
|
||||||
|
setToken("");
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingUser(false));
|
||||||
|
}, [client, token]);
|
||||||
|
|
||||||
|
const onAuthenticated = (nextToken: string, user: PublicUser) => {
|
||||||
|
localStorage.setItem("wallmuse_admin_token", nextToken);
|
||||||
|
setToken(nextToken);
|
||||||
|
setCurrentUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("wallmuse_admin_token");
|
||||||
|
setToken("");
|
||||||
|
setCurrentUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingUser) {
|
||||||
|
return <FullPageStatus label="正在验证管理会话" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !currentUser) {
|
||||||
|
return <LoginPage onAuthenticated={onAuthenticated} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell
|
||||||
|
client={client}
|
||||||
|
currentUser={currentUser}
|
||||||
|
theme={theme}
|
||||||
|
view={view}
|
||||||
|
onChangeView={setView}
|
||||||
|
onToggleTheme={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPage({ onAuthenticated }: { onAuthenticated: (token: string, user: PublicUser) => void }) {
|
||||||
|
const [email, setEmail] = useState("admin@wallmuse.local");
|
||||||
|
const [password, setPassword] = useState("password123");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const auth = await createClient().login(email, password);
|
||||||
|
if (!auth.user.roles.some((role) => role === "admin" || role === "super_admin")) {
|
||||||
|
setError("该账号没有后台管理权限");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAuthenticated(auth.token, auth.user);
|
||||||
|
} catch {
|
||||||
|
setError("登录失败,请确认 API 已启动并存在管理员账号");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="login-shell glass-panel">
|
||||||
|
<div className="brand-block">
|
||||||
|
<LogoMark />
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">WallMuse Admin</p>
|
||||||
|
<h1>运营后台</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="login-card" onSubmit={submit}>
|
||||||
|
<label>
|
||||||
|
管理员邮箱
|
||||||
|
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
密码
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
<button className="primary-button" type="submit" disabled={submitting}>
|
||||||
|
<Lock size={16} />
|
||||||
|
{submitting ? "登录中" : "进入后台"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminShell(props: {
|
||||||
|
client: WallMuseApiClient;
|
||||||
|
currentUser: PublicUser;
|
||||||
|
theme: Theme;
|
||||||
|
view: ViewKey;
|
||||||
|
onChangeView: (view: ViewKey) => void;
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}) {
|
||||||
|
const active = navItems.find((item) => item.key === props.view) ?? navItems[0]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<div className="admin-shell glass-panel">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<LogoMark />
|
||||||
|
<div>
|
||||||
|
<strong>WallMuse</strong>
|
||||||
|
<span>Control Center</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="nav-list">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className={item.key === props.view ? "active" : ""}
|
||||||
|
onClick={() => props.onChangeView(item.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-status">
|
||||||
|
<span className="status-dot" />
|
||||||
|
API client routed
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section className="workspace">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">管理后台</p>
|
||||||
|
<h1>{active.label}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<label className="search-box">
|
||||||
|
<Search size={16} />
|
||||||
|
<input placeholder="搜索用户、任务、模型" />
|
||||||
|
</label>
|
||||||
|
<button className="icon-button" onClick={props.onToggleTheme} type="button" title="切换主题">
|
||||||
|
{props.theme === "light" ? <Moon size={17} /> : <Sun size={17} />}
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" title="通知">
|
||||||
|
<Bell size={17} />
|
||||||
|
</button>
|
||||||
|
<div className="user-chip">
|
||||||
|
<span>{props.currentUser.name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{props.currentUser.name}</strong>
|
||||||
|
<small>{props.currentUser.roles.join(" / ")}</small>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</div>
|
||||||
|
<button className="icon-button danger" onClick={props.onLogout} type="button" title="退出">
|
||||||
|
<LogOut size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="content">{renderView(props.view, props.client)}</main>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderView(view: ViewKey, client: WallMuseApiClient) {
|
||||||
|
if (view === "dashboard") return <DashboardView client={client} />;
|
||||||
|
if (view === "users") return <UsersView client={client} />;
|
||||||
|
if (view === "providers") return <ProvidersView client={client} />;
|
||||||
|
if (view === "models") return <ModelsView client={client} />;
|
||||||
|
if (view === "tasks") return <TasksView client={client} />;
|
||||||
|
if (view === "gallery") return <GalleryView client={client} />;
|
||||||
|
return <SettingsView client={client} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardView({ client }: { client: WallMuseApiClient }) {
|
||||||
|
const { data, loading, reload } = useApiData(() => client.getDashboard(), [client]);
|
||||||
|
|
||||||
|
if (loading || !data) return <SectionSkeleton title="仪表盘数据加载中" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="view-stack">
|
||||||
|
<Toolbar title="核心指标" description="生成、成本、队列和内容审核概览" onRefresh={reload} />
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<KpiCard label="今日生成" value={data.kpis.todayGenerations.toLocaleString()} delta="+18%" tone="accent" />
|
||||||
|
<KpiCard label="成功率" value={`${data.kpis.successRate}%`} delta="-0.7%" tone="success" />
|
||||||
|
<KpiCard label="失败率" value={`${data.kpis.failedRate}%`} delta="+0.4%" tone="danger" />
|
||||||
|
<KpiCard label="消耗积分" value={data.kpis.costCredits.toLocaleString()} delta="+9%" tone="warning" />
|
||||||
|
<KpiCard label="活跃用户" value={data.kpis.activeUsers.toLocaleString()} delta="+6%" tone="success" />
|
||||||
|
<KpiCard label="待审图库" value={data.kpis.galleryPending.toLocaleString()} delta="需处理" tone="warning" />
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<TrendPanel data={data} />
|
||||||
|
<QueuePanel data={data} />
|
||||||
|
</div>
|
||||||
|
<div className="split-grid">
|
||||||
|
<ProviderHealth providers={data.providers} />
|
||||||
|
<TaskTable tasks={data.recentTasks} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersView({ client }: { client: WallMuseApiClient }) {
|
||||||
|
const { data } = useApiData(() => client.getUsers(), [client]);
|
||||||
|
return (
|
||||||
|
<DataPanel title="用户管理" description="查询、禁用、额度和 API Key 风险概览">
|
||||||
|
<table className="dense-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>套餐</th>
|
||||||
|
<th>积分</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
<th>生成数</th>
|
||||||
|
<th>最近活跃</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data ?? []).map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>
|
||||||
|
<strong>{user.name}</strong>
|
||||||
|
<small>{user.email}</small>
|
||||||
|
</td>
|
||||||
|
<td><StatusPill status={user.status} /></td>
|
||||||
|
<td>{user.plan}</td>
|
||||||
|
<td>{user.credits.toLocaleString()}</td>
|
||||||
|
<td>{user.apiKeyCount}</td>
|
||||||
|
<td>{user.generationCount}</td>
|
||||||
|
<td>{formatDate(user.lastActiveAt)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DataPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvidersView({ client }: { client: WallMuseApiClient }) {
|
||||||
|
const { data } = useApiData(() => client.getProviders(), [client]);
|
||||||
|
return (
|
||||||
|
<DataPanel title="供应商管理" description="Base URL、鉴权方式、限流和健康检查集中配置">
|
||||||
|
<table className="dense-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>供应商</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>Base URL</th>
|
||||||
|
<th>Key 模式</th>
|
||||||
|
<th>模型</th>
|
||||||
|
<th>日限额</th>
|
||||||
|
<th>成功率</th>
|
||||||
|
<th>延迟</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data ?? []).map((provider) => (
|
||||||
|
<tr key={provider.id}>
|
||||||
|
<td>
|
||||||
|
<strong>{provider.name}</strong>
|
||||||
|
<small>{provider.code}</small>
|
||||||
|
</td>
|
||||||
|
<td><StatusPill status={provider.status} /></td>
|
||||||
|
<td className="mono">{provider.baseUrl}</td>
|
||||||
|
<td>{provider.keyMode}</td>
|
||||||
|
<td>{provider.modelCount}</td>
|
||||||
|
<td>{provider.dailyLimit.toLocaleString()}</td>
|
||||||
|
<td>{provider.successRate}%</td>
|
||||||
|
<td>{provider.avgLatencyMs}ms</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DataPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelsView({ client }: { client: WallMuseApiClient }) {
|
||||||
|
const { data } = useApiData(() => client.getModels(), [client]);
|
||||||
|
return (
|
||||||
|
<DataPanel title="模型管理" description="模型能力、尺寸映射、价格和启停状态">
|
||||||
|
<div className="model-grid">
|
||||||
|
{(data ?? []).map((model) => (
|
||||||
|
<article className="model-card" key={model.id}>
|
||||||
|
<div className="model-head">
|
||||||
|
<div>
|
||||||
|
<strong>{model.name}</strong>
|
||||||
|
<small className="mono">{model.modelId}</small>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={model.status} />
|
||||||
|
</div>
|
||||||
|
<div className="tag-row">{model.modes.map((item) => <span key={item}>{item}</span>)}</div>
|
||||||
|
<dl className="metric-list">
|
||||||
|
<div><dt>分辨率</dt><dd>{model.resolutions.join(" / ")}</dd></div>
|
||||||
|
<div><dt>比例</dt><dd>{model.ratios.join(" / ")}</dd></div>
|
||||||
|
<div><dt>原生 4K</dt><dd>{model.native4k ? "支持" : "超分"}</dd></div>
|
||||||
|
<div><dt>价格</dt><dd>{model.priceCredits} credits</dd></div>
|
||||||
|
<div><dt>批量</dt><dd>{model.maxBatchSize}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DataPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksView({ client }: { client: WallMuseApiClient }) {
|
||||||
|
const { data, reload } = useApiData(() => client.getTasks(), [client]);
|
||||||
|
return (
|
||||||
|
<DataPanel title="任务管理" description="队列状态、重试、错误详情和手动取消入口" onRefresh={reload}>
|
||||||
|
<TaskTable tasks={data ?? []} />
|
||||||
|
</DataPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GalleryView({ client }: { client: WallMuseApiClient }) {
|
||||||
|
const { data } = useApiData(() => client.getGalleryReviewItems(), [client]);
|
||||||
|
return (
|
||||||
|
<DataPanel title="图库审核" description="公开图库入库审核、推荐、标签和风险等级">
|
||||||
|
<div className="gallery-review-grid">
|
||||||
|
{(data ?? []).map((item) => (
|
||||||
|
<article className="review-card" key={item.id}>
|
||||||
|
<img src={item.imageUrl} alt={item.title} />
|
||||||
|
<div className="review-body">
|
||||||
|
<div className="model-head">
|
||||||
|
<div>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<small>{item.authorEmail}</small>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={item.status} />
|
||||||
|
</div>
|
||||||
|
<p>{item.prompt}</p>
|
||||||
|
<div className="tag-row">
|
||||||
|
<span>{item.ratio}</span>
|
||||||
|
<span>{item.resolution}</span>
|
||||||
|
<span>risk: {item.riskLevel}</span>
|
||||||
|
{item.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="review-actions">
|
||||||
|
<button type="button"><CheckCircle2 size={15} />通过</button>
|
||||||
|
<button type="button"><Sparkles size={15} />推荐</button>
|
||||||
|
<button className="danger" type="button"><XCircle size={15} />拒绝</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DataPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsView({ client }: { client: WallMuseApiClient }) {
|
||||||
|
const { data } = useApiData(() => client.getSystemSettings(), [client]);
|
||||||
|
const settings = data as SystemSettings | undefined;
|
||||||
|
return (
|
||||||
|
<DataPanel title="系统设置" description="站点、图库、内容安全、存储、CDN 与下载策略">
|
||||||
|
{settings ? (
|
||||||
|
<div className="settings-grid">
|
||||||
|
<SettingsField label="站点名称" value={settings.siteName} />
|
||||||
|
<SettingsField label="支持邮箱" value={settings.supportEmail} />
|
||||||
|
<SettingsField label="默认分辨率" value={settings.defaultResolution} />
|
||||||
|
<SettingsField label="对象存储" value={settings.storageProvider} />
|
||||||
|
<SettingsField label="CDN Base URL" value={settings.cdnBaseUrl} wide />
|
||||||
|
<ToggleRow label="公开图库" active={settings.publicGalleryEnabled} />
|
||||||
|
<ToggleRow label="注册入口" active={settings.registrationEnabled} />
|
||||||
|
<ToggleRow label="Prompt 审核" active={settings.promptModerationEnabled} />
|
||||||
|
<ToggleRow label="下载水印" active={settings.watermarkEnabled} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SectionSkeleton title="系统设置加载中" />
|
||||||
|
)}
|
||||||
|
</DataPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskTable({ tasks, compact = false }: { tasks: AdminTask[]; compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<table className={`dense-table ${compact ? "compact-table" : ""}`}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>任务</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>输出</th>
|
||||||
|
<th>分辨率</th>
|
||||||
|
<th>进度</th>
|
||||||
|
<th>成本</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<tr key={task.id}>
|
||||||
|
<td>
|
||||||
|
<strong>{task.id}</strong>
|
||||||
|
<small>{task.prompt}</small>
|
||||||
|
</td>
|
||||||
|
<td><StatusPill status={task.status} /></td>
|
||||||
|
<td>{task.userEmail}</td>
|
||||||
|
<td>{task.outputs.join(" + ")}</td>
|
||||||
|
<td>{task.resolutionTier}</td>
|
||||||
|
<td><Progress value={task.progress} /></td>
|
||||||
|
<td>{task.costCredits}</td>
|
||||||
|
<td>{formatDate(task.updatedAt)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendPanel({ data }: { data: AdminDashboard }) {
|
||||||
|
const max = Math.max(...data.generationTrend.map((item) => item.value));
|
||||||
|
return (
|
||||||
|
<section className="panel chart-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>七日生成趋势</h2>
|
||||||
|
<p>按任务组统计,失败量叠加显示</p>
|
||||||
|
</div>
|
||||||
|
<SlidersHorizontal size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="bar-chart">
|
||||||
|
{data.generationTrend.map((item) => (
|
||||||
|
<div className="bar-item" key={item.label}>
|
||||||
|
<div className="bar-track">
|
||||||
|
<span style={{ height: `${(item.value / max) * 100}%` }} />
|
||||||
|
<i style={{ height: `${Math.max(8, (item.failed / max) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<small>{item.label}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueuePanel({ data }: { data: AdminDashboard }) {
|
||||||
|
return (
|
||||||
|
<section className="panel queue-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>队列状态</h2>
|
||||||
|
<p>Worker 消费与失败积压</p>
|
||||||
|
</div>
|
||||||
|
<Activity size={18} />
|
||||||
|
</div>
|
||||||
|
<dl className="queue-metrics">
|
||||||
|
<div><dt>等待</dt><dd>{data.queue.waiting}</dd></div>
|
||||||
|
<div><dt>运行</dt><dd>{data.queue.running}</dd></div>
|
||||||
|
<div><dt>失败</dt><dd>{data.queue.failed}</dd></div>
|
||||||
|
<div><dt>P95 延迟</dt><dd>{data.queue.p95LatencyMs}ms</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderHealth({ providers }: { providers: AdminProvider[] }) {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>供应商健康</h2>
|
||||||
|
<p>成功率、延迟和限额</p>
|
||||||
|
</div>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="provider-list">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<div className="provider-row" key={provider.id}>
|
||||||
|
<div>
|
||||||
|
<strong>{provider.name}</strong>
|
||||||
|
<small>{provider.avgLatencyMs}ms avg</small>
|
||||||
|
</div>
|
||||||
|
<Progress value={provider.successRate} />
|
||||||
|
<StatusPill status={provider.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataPanel(props: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onRefresh?: (() => void) | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="panel data-panel">
|
||||||
|
<Toolbar title={props.title} description={props.description} onRefresh={props.onRefresh} />
|
||||||
|
{props.children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toolbar({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onRefresh
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onRefresh?: (() => void) | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="panel-head toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-actions">
|
||||||
|
<button type="button"><Search size={15} />筛选</button>
|
||||||
|
<button type="button" onClick={onRefresh}><RefreshCcw size={15} />刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, delta, tone }: { label: string; value: string; delta: string; tone: string }) {
|
||||||
|
return (
|
||||||
|
<article className={`kpi-card tone-${tone}`}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
<small>{delta}</small>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
return <span className={`status-pill status-${status.replace("_", "-")}`}>{status}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Progress({ value }: { value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="progress">
|
||||||
|
<span style={{ width: `${Math.min(Math.max(value, 0), 100)}%` }} />
|
||||||
|
<small>{Math.round(value)}%</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsField({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<label className={wide ? "wide" : ""}>
|
||||||
|
{label}
|
||||||
|
<input value={value} readOnly />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({ label, active }: { label: string; active: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="toggle-row">
|
||||||
|
<span>{label}</span>
|
||||||
|
<button className={active ? "toggle active" : "toggle"} type="button" aria-pressed={active}>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogoMark() {
|
||||||
|
return (
|
||||||
|
<div className="logo-mark" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionSkeleton({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<section className="panel skeleton">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
<span>{title}</span>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullPageStatus({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="login-shell glass-panel skeleton">
|
||||||
|
<LogoMark />
|
||||||
|
<span>{label}</span>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useApiData<T>(loader: () => Promise<T>, deps: React.DependencyList) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
loader()
|
||||||
|
.then((next) => {
|
||||||
|
if (active) setData(next);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [...deps, version]);
|
||||||
|
|
||||||
|
return { data, loading, reload: () => setVersion((value) => value + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value: string) =>
|
||||||
|
new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(new Date(value));
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
803
apps/admin/src/styles.css
Executable file
803
apps/admin/src/styles.css
Executable file
@@ -0,0 +1,803 @@
|
|||||||
|
:root {
|
||||||
|
--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;
|
||||||
|
--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%);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
font-size: var(--font-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 1180px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, var(--color-bg-page), rgba(210, 232, 255, 0.66) 48%, var(--color-bg-page)),
|
||||||
|
var(--color-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page,
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel,
|
||||||
|
.panel,
|
||||||
|
.kpi-card,
|
||||||
|
.model-card,
|
||||||
|
.review-card {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
|
max-width: 1800px;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 28px 18px;
|
||||||
|
border-right: 1px solid var(--color-border-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .sidebar {
|
||||||
|
background: rgba(10, 20, 35, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.brand-block,
|
||||||
|
.user-chip,
|
||||||
|
.topbar-actions,
|
||||||
|
.panel-head,
|
||||||
|
.toolbar-actions,
|
||||||
|
.review-actions,
|
||||||
|
.model-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.brand-block {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span,
|
||||||
|
small,
|
||||||
|
.panel-head p,
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 5px);
|
||||||
|
align-items: end;
|
||||||
|
gap: 3px;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-control-active);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark span {
|
||||||
|
display: block;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #8fc8ff, var(--color-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark span:nth-child(1) { height: 14px; }
|
||||||
|
.logo-mark span:nth-child(2) { height: 22px; }
|
||||||
|
.logo-mark span:nth-child(3) { height: 18px; }
|
||||||
|
.logo-mark span:nth-child(4) { height: 27px; }
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list button,
|
||||||
|
.toolbar-actions button,
|
||||||
|
.review-actions button,
|
||||||
|
.icon-button,
|
||||||
|
.primary-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list button.active,
|
||||||
|
.primary-button {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--color-accent), #6fb5ff);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 12px 24px rgba(47, 134, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-status {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 88px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 18px 28px;
|
||||||
|
border-bottom: 1px solid var(--color-border-soft);
|
||||||
|
background: var(--color-bg-surface-strong);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-2xl);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 280px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input,
|
||||||
|
.login-card input,
|
||||||
|
.settings-grid input {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 190px;
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip > span {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip strong,
|
||||||
|
.user-chip small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip small {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px 28px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions button,
|
||||||
|
.review-actions button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 118px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card span,
|
||||||
|
.kpi-card small {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card strong {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-accent { border-color: rgba(47, 134, 255, 0.3); }
|
||||||
|
.tone-success { border-color: rgba(32, 201, 151, 0.3); }
|
||||||
|
.tone-danger { border-color: rgba(255, 92, 122, 0.3); }
|
||||||
|
.tone-warning { border-color: rgba(247, 185, 85, 0.34); }
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 0.7fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 0.9fr) minmax(0, 1.4fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
height: 250px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
height: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track span,
|
||||||
|
.bar-track i {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 42%;
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track span {
|
||||||
|
background: linear-gradient(180deg, #83c2ff, var(--color-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track i {
|
||||||
|
right: 12%;
|
||||||
|
width: 16%;
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-metrics,
|
||||||
|
.metric-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-metrics {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-metrics div,
|
||||||
|
.metric-list div {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress > span {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-bg-control-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress > span::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress small {
|
||||||
|
min-width: 34px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table {
|
||||||
|
width: 100%;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: separate;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-surface-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table th,
|
||||||
|
.dense-table td {
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-soft);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table td strong,
|
||||||
|
.dense-table td small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-table td small {
|
||||||
|
max-width: 360px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table td small {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: "JetBrains Mono", SFMono-Regular, monospace;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-control-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active,
|
||||||
|
.status-succeeded,
|
||||||
|
.status-approved,
|
||||||
|
.status-featured {
|
||||||
|
color: #077457;
|
||||||
|
background: rgba(32, 201, 151, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running,
|
||||||
|
.status-degraded,
|
||||||
|
.status-pending,
|
||||||
|
.status-queued,
|
||||||
|
.status-risk,
|
||||||
|
.status-draft {
|
||||||
|
color: #966a09;
|
||||||
|
background: rgba(247, 185, 85, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-failed,
|
||||||
|
.status-disabled,
|
||||||
|
.status-error,
|
||||||
|
.status-rejected,
|
||||||
|
.status-canceled {
|
||||||
|
color: #b72645;
|
||||||
|
background: rgba(255, 92, 122, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-row span {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-review-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-body p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-actions .danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid label.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid input {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle span {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.active span {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
width: min(920px, calc(100vw - 48px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 360px;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 38px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block h1 {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-surface-strong);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card input {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 180px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1320px) {
|
||||||
|
.kpi-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid,
|
||||||
|
.split-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/admin/tsconfig.json
Executable file
12
apps/admin/tsconfig.json
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"composite": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
|
||||||
|
"references": [{ "path": "../../packages/api-client" }]
|
||||||
|
}
|
||||||
15
apps/admin/vite.config.ts
Executable file
15
apps/admin/vite.config.ts
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3200,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.WALLMUSE_API_BASE_URL ?? "http://127.0.0.1:4000",
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
25
apps/api/package.json
Executable file
25
apps/api/package.json
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"smoke": "tsx src/smoke-test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wallmuse/api-client": "workspace:*",
|
||||||
|
"@wallmuse/db": "workspace:*",
|
||||||
|
"@wallmuse/shared": "workspace:*",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/cors": "^11.0.1",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/sensible": "^6.0.3",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"fastify": "^5.6.2",
|
||||||
|
"nanoid": "^5.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/api/src/auth.ts
Executable file
51
apps/api/src/auth.ts
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import type { PublicUser, UserRole } from "@wallmuse/shared";
|
||||||
|
import { hasAdminRole, toPublicUser, type JsonWallMuseDb } from "@wallmuse/db";
|
||||||
|
import { ApiError } from "./errors.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
db: JsonWallMuseDb;
|
||||||
|
}
|
||||||
|
interface FastifyRequest {
|
||||||
|
currentUser?: PublicUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
roles: UserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractToken = (request: FastifyRequest): string | undefined => {
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
if (header?.startsWith("Bearer ")) {
|
||||||
|
return header.slice("Bearer ".length);
|
||||||
|
}
|
||||||
|
const token = request.cookies?.wallmuse_token;
|
||||||
|
return typeof token === "string" ? token : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAuth = async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (!token) {
|
||||||
|
throw new ApiError(401, "UNAUTHORIZED", "Authentication is required");
|
||||||
|
}
|
||||||
|
const payload = await request.server.jwt.verify<JwtPayload>(token);
|
||||||
|
const data = await request.server.db.read();
|
||||||
|
const user = data.users.find((item) => item.id === payload.sub);
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError(401, "UNAUTHORIZED", "User no longer exists");
|
||||||
|
}
|
||||||
|
request.currentUser = toPublicUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAdmin = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||||
|
await requireAuth(request, reply);
|
||||||
|
if (!request.currentUser || !hasAdminRole(request.currentUser.roles)) {
|
||||||
|
throw new ApiError(403, "FORBIDDEN", "Administrator permission is required");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signUserToken = async (app: FastifyInstance, user: PublicUser): Promise<string> =>
|
||||||
|
app.jwt.sign({ sub: user.id, roles: user.roles } satisfies JwtPayload, { expiresIn: "7d" });
|
||||||
22
apps/api/src/errors.ts
Executable file
22
apps/api/src/errors.ts
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { FastifyReply } from "fastify";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly statusCode: number,
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: unknown
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendError = (reply: FastifyReply, error: ApiError): void => {
|
||||||
|
void reply.status(error.statusCode).send({
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
details: error.details
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
380
apps/api/src/routes.ts
Executable file
380
apps/api/src/routes.ts
Executable file
@@ -0,0 +1,380 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { apiPaths } from "@wallmuse/api-client";
|
||||||
|
import {
|
||||||
|
CreateGenerationRequestSchema,
|
||||||
|
CreateModelRequestSchema,
|
||||||
|
CreateProviderRequestSchema,
|
||||||
|
CreateUserApiKeyRequestSchema,
|
||||||
|
LoginRequestSchema,
|
||||||
|
RegisterRequestSchema,
|
||||||
|
UpdateModelRequestSchema,
|
||||||
|
UpdateProviderRequestSchema,
|
||||||
|
type CreateGenerationRequest,
|
||||||
|
type ModelSummary,
|
||||||
|
type ProviderSummary,
|
||||||
|
type UserRole
|
||||||
|
} from "@wallmuse/shared";
|
||||||
|
import { makeGenerationAssets, toPublicUser, type ProviderRecord } from "@wallmuse/db";
|
||||||
|
import { requireAdmin, requireAuth, signUserToken } from "./auth.js";
|
||||||
|
import { ApiError } from "./errors.js";
|
||||||
|
import { decryptApiKey, encryptApiKey, hashPassword, maskApiKey, verifyPassword } from "./security.js";
|
||||||
|
|
||||||
|
const parseBody = <T>(schema: { parse: (input: unknown) => T }, request: FastifyRequest): T => {
|
||||||
|
try {
|
||||||
|
return schema.parse(request.body);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiError(400, "VALIDATION_ERROR", "Request body validation failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIdParam = (request: FastifyRequest): string => {
|
||||||
|
const params = request.params as { id?: string };
|
||||||
|
if (!params.id) {
|
||||||
|
throw new ApiError(400, "VALIDATION_ERROR", "Missing id parameter");
|
||||||
|
}
|
||||||
|
return params.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendAuth = async (
|
||||||
|
app: FastifyInstance,
|
||||||
|
reply: FastifyReply,
|
||||||
|
user: ReturnType<typeof toPublicUser>
|
||||||
|
) => {
|
||||||
|
const token = await signUserToken(app, user);
|
||||||
|
reply.setCookie("wallmuse_token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 7 * 24 * 60 * 60
|
||||||
|
});
|
||||||
|
return { user, token };
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertCurrentUser = (request: FastifyRequest) => {
|
||||||
|
if (!request.currentUser) {
|
||||||
|
throw new ApiError(401, "UNAUTHORIZED", "Authentication is required");
|
||||||
|
}
|
||||||
|
return request.currentUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeApiKey = <T extends { encryptedKey: string }>(apiKey: T): Omit<T, "encryptedKey"> => {
|
||||||
|
const { encryptedKey: _encryptedKey, ...safe } = apiKey;
|
||||||
|
return safe;
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerMatches = (provider: ProviderSummary, idOrSlug: string): boolean =>
|
||||||
|
provider.id === idOrSlug || provider.slug === idOrSlug;
|
||||||
|
|
||||||
|
const modelMatches = (model: ModelSummary, idOrSlug: string): boolean =>
|
||||||
|
model.id === idOrSlug || model.slug === idOrSlug;
|
||||||
|
|
||||||
|
const ensureProviderAndModel = async (
|
||||||
|
app: FastifyInstance,
|
||||||
|
providerId: string | undefined,
|
||||||
|
modelId: string
|
||||||
|
): Promise<{ provider: ProviderSummary; model: ModelSummary }> => {
|
||||||
|
const data = await app.db.read();
|
||||||
|
const model = data.models.find((item) => modelMatches(item, modelId) && item.status === "enabled");
|
||||||
|
if (!model) {
|
||||||
|
throw new ApiError(400, "MODEL_NOT_AVAILABLE", "Model is not available");
|
||||||
|
}
|
||||||
|
const provider = data.providers.find((item) => providerMatches(item, providerId ?? model.providerId));
|
||||||
|
if (!provider || provider.status === "disabled" || provider.status === "error") {
|
||||||
|
throw new ApiError(400, "PROVIDER_NOT_AVAILABLE", "Provider is not available");
|
||||||
|
}
|
||||||
|
if (model.providerId !== provider.id) {
|
||||||
|
throw new ApiError(400, "MODEL_PROVIDER_MISMATCH", "Model does not belong to provider");
|
||||||
|
}
|
||||||
|
return { provider, model };
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeTask = (
|
||||||
|
groupId: string,
|
||||||
|
input: CreateGenerationRequest,
|
||||||
|
aspectRatio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9",
|
||||||
|
now: string
|
||||||
|
) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
groupId,
|
||||||
|
status: "queued" as const,
|
||||||
|
mode: input.mode,
|
||||||
|
aspectRatio,
|
||||||
|
resolution: input.resolution,
|
||||||
|
quality: input.quality,
|
||||||
|
attempt: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
progress: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerRoutes = async (app: FastifyInstance): Promise<void> => {
|
||||||
|
app.get("/api/v1/health", async () => ({
|
||||||
|
ok: true as const,
|
||||||
|
service: "wallmuse-api" as const,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get(apiPaths.appConfig, async () => {
|
||||||
|
const data = await app.db.read();
|
||||||
|
return app.db.getAppConfig(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/auth/register", async (request, reply) => {
|
||||||
|
const input = parseBody(RegisterRequestSchema, request);
|
||||||
|
const normalizedEmail = input.email.toLowerCase();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const user = await app.db.mutate(async (data) => {
|
||||||
|
if (data.users.some((item) => item.email.toLowerCase() === normalizedEmail)) {
|
||||||
|
throw new ApiError(409, "EMAIL_EXISTS", "Email is already registered");
|
||||||
|
}
|
||||||
|
const roles: UserRole[] = data.users.length === 0 ? ["user", "admin", "super_admin"] : ["user"];
|
||||||
|
const created = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
email: normalizedEmail,
|
||||||
|
name: input.name,
|
||||||
|
roles,
|
||||||
|
passwordHash: await hashPassword(input.password),
|
||||||
|
createdAt: now
|
||||||
|
};
|
||||||
|
data.users.push(created);
|
||||||
|
return toPublicUser(created);
|
||||||
|
});
|
||||||
|
return sendAuth(app, reply, user);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/auth/login", async (request, reply) => {
|
||||||
|
const input = parseBody(LoginRequestSchema, request);
|
||||||
|
const data = await app.db.read();
|
||||||
|
const user = data.users.find((item) => item.email.toLowerCase() === input.email.toLowerCase());
|
||||||
|
if (!user || !(await verifyPassword(input.password, user.passwordHash))) {
|
||||||
|
throw new ApiError(401, "INVALID_CREDENTIALS", "Invalid email or password");
|
||||||
|
}
|
||||||
|
return sendAuth(app, reply, toPublicUser(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/me", { preHandler: requireAuth }, async (request) => ({
|
||||||
|
user: assertCurrentUser(request)
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get(apiPaths.providers, async () => {
|
||||||
|
const data = await app.db.read();
|
||||||
|
return data.providers;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/providers", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const input = parseBody(CreateProviderRequestSchema, request);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const provider = await app.db.mutate((data) => {
|
||||||
|
if (data.providers.some((item) => item.slug === input.slug)) {
|
||||||
|
throw new ApiError(409, "PROVIDER_EXISTS", "Provider slug already exists");
|
||||||
|
}
|
||||||
|
const created: ProviderRecord = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
modelCount: 0,
|
||||||
|
...input,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
data.providers.push(created);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return provider;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/v1/providers/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const id = getIdParam(request);
|
||||||
|
const input = parseBody(UpdateProviderRequestSchema, request);
|
||||||
|
const provider = await app.db.mutate((data) => {
|
||||||
|
const existing = data.providers.find((item) => item.id === id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new ApiError(404, "PROVIDER_NOT_FOUND", "Provider not found");
|
||||||
|
}
|
||||||
|
Object.assign(existing, input, { updatedAt: new Date().toISOString() });
|
||||||
|
return existing;
|
||||||
|
});
|
||||||
|
return provider;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/v1/providers/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const id = getIdParam(request);
|
||||||
|
await app.db.mutate((data) => {
|
||||||
|
data.providers = data.providers.filter((item) => item.id !== id);
|
||||||
|
data.models = data.models.filter((item) => item.providerId !== id);
|
||||||
|
});
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(apiPaths.models, async (request) => {
|
||||||
|
const providerId = (request.query as { providerId?: string }).providerId;
|
||||||
|
const data = await app.db.read();
|
||||||
|
return providerId ? data.models.filter((item) => item.providerId === providerId) : data.models;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/models", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const input = parseBody(CreateModelRequestSchema, request);
|
||||||
|
const model = await app.db.mutate((data) => {
|
||||||
|
const provider = data.providers.find((item) => item.id === input.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new ApiError(400, "PROVIDER_NOT_FOUND", "Provider not found");
|
||||||
|
}
|
||||||
|
const created: ModelSummary = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...input
|
||||||
|
};
|
||||||
|
data.models.push(created);
|
||||||
|
provider.modelCount = data.models.filter((item) => item.providerId === provider.id).length;
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return model;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/v1/models/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const id = getIdParam(request);
|
||||||
|
const input = parseBody(UpdateModelRequestSchema, request);
|
||||||
|
const model = await app.db.mutate((data) => {
|
||||||
|
const existing = data.models.find((item) => item.id === id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new ApiError(404, "MODEL_NOT_FOUND", "Model not found");
|
||||||
|
}
|
||||||
|
Object.assign(existing, input);
|
||||||
|
return existing;
|
||||||
|
});
|
||||||
|
return model;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/v1/models/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const id = getIdParam(request);
|
||||||
|
await app.db.mutate((data) => {
|
||||||
|
const existing = data.models.find((item) => item.id === id);
|
||||||
|
data.models = data.models.filter((item) => item.id !== id);
|
||||||
|
if (existing) {
|
||||||
|
const provider = data.providers.find((item) => item.id === existing.providerId);
|
||||||
|
if (provider) {
|
||||||
|
provider.modelCount = data.models.filter((item) => item.providerId === provider.id).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/user-api-keys", { preHandler: requireAuth }, async (request) => {
|
||||||
|
const user = assertCurrentUser(request);
|
||||||
|
const data = await app.db.read();
|
||||||
|
return data.userApiKeys.filter((item) => item.userId === user.id).map(safeApiKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/user-api-keys", { preHandler: requireAuth }, async (request) => {
|
||||||
|
const user = assertCurrentUser(request);
|
||||||
|
const input = parseBody(CreateUserApiKeyRequestSchema, request);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const apiKey = await app.db.mutate((data) => {
|
||||||
|
if (!data.providers.some((item) => item.id === input.providerId)) {
|
||||||
|
throw new ApiError(400, "PROVIDER_NOT_FOUND", "Provider not found");
|
||||||
|
}
|
||||||
|
const created = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: user.id,
|
||||||
|
providerId: input.providerId,
|
||||||
|
name: input.name,
|
||||||
|
maskedKey: maskApiKey(input.apiKey),
|
||||||
|
encryptedKey: encryptApiKey(input.apiKey),
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
defaultModelId: input.defaultModelId,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
data.userApiKeys.push(created);
|
||||||
|
return safeApiKey(created);
|
||||||
|
});
|
||||||
|
return apiKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/v1/user-api-keys/:id", { preHandler: requireAuth }, async (request) => {
|
||||||
|
const user = assertCurrentUser(request);
|
||||||
|
const id = getIdParam(request);
|
||||||
|
await app.db.mutate((data) => {
|
||||||
|
data.userApiKeys = data.userApiKeys.filter((item) => !(item.id === id && item.userId === user.id));
|
||||||
|
});
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/user-api-keys/:id/test", { preHandler: requireAuth }, async (request) => {
|
||||||
|
const user = assertCurrentUser(request);
|
||||||
|
const id = getIdParam(request);
|
||||||
|
const data = await app.db.read();
|
||||||
|
const apiKey = data.userApiKeys.find((item) => item.id === id && item.userId === user.id);
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new ApiError(404, "API_KEY_NOT_FOUND", "API key not found");
|
||||||
|
}
|
||||||
|
decryptApiKey(apiKey.encryptedKey);
|
||||||
|
return { ok: true as const, providerId: apiKey.providerId, testedAt: new Date().toISOString() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(apiPaths.generations, { preHandler: requireAuth }, async (request) => {
|
||||||
|
const user = assertCurrentUser(request);
|
||||||
|
const input = parseBody(CreateGenerationRequestSchema, request);
|
||||||
|
const { provider, model } = await ensureProviderAndModel(app, undefined, input.modelId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const generationGroup = await app.db.mutate((data) => {
|
||||||
|
const groupId = crypto.randomUUID();
|
||||||
|
const tasks = input.aspectRatios.map((aspectRatio) => makeTask(groupId, input, aspectRatio, now));
|
||||||
|
const taskIds = {
|
||||||
|
"16:9": tasks.find((task) => task.aspectRatio === "16:9")?.id,
|
||||||
|
"9:16": tasks.find((task) => task.aspectRatio === "9:16")?.id
|
||||||
|
};
|
||||||
|
const created = {
|
||||||
|
id: groupId,
|
||||||
|
userId: user.id,
|
||||||
|
providerId: provider.id,
|
||||||
|
privacy: input.publishToGallery ? "public" : "private",
|
||||||
|
status: "queued" as const,
|
||||||
|
modelId: model.id,
|
||||||
|
prompt: input.prompt,
|
||||||
|
negativePrompt: input.negativePrompt,
|
||||||
|
tasks,
|
||||||
|
assets: makeGenerationAssets(taskIds, now),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
data.generationGroups.push(created);
|
||||||
|
data.providerCallLogs.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
taskId: created.id,
|
||||||
|
providerId: provider.id,
|
||||||
|
modelId: model.id,
|
||||||
|
status: "success",
|
||||||
|
latencyMs: 0,
|
||||||
|
createdAt: now
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return { generationGroup, pollingUrl: apiPaths.generationGroup(generationGroup.id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/generation-groups/:id", { preHandler: requireAuth }, async (request) => {
|
||||||
|
const user = assertCurrentUser(request);
|
||||||
|
const id = getIdParam(request);
|
||||||
|
const data = await app.db.read();
|
||||||
|
const generationGroup = data.generationGroups.find((item) => item.id === id);
|
||||||
|
if (!generationGroup) {
|
||||||
|
throw new ApiError(404, "GENERATION_GROUP_NOT_FOUND", "Generation group not found");
|
||||||
|
}
|
||||||
|
const isAdmin = user.roles.includes("admin") || user.roles.includes("super_admin");
|
||||||
|
if (generationGroup.userId !== user.id && !isAdmin) {
|
||||||
|
throw new ApiError(403, "FORBIDDEN", "Cannot access another user's generation group");
|
||||||
|
}
|
||||||
|
return generationGroup;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/admin/tasks", { preHandler: requireAdmin }, async () => {
|
||||||
|
const data = await app.db.read();
|
||||||
|
return data.generationGroups;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/admin/provider-logs", { preHandler: requireAdmin }, async () => {
|
||||||
|
const data = await app.db.read();
|
||||||
|
return data.providerCallLogs;
|
||||||
|
});
|
||||||
|
};
|
||||||
42
apps/api/src/security.ts
Executable file
42
apps/api/src/security.ts
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const algorithm = "aes-256-gcm";
|
||||||
|
|
||||||
|
const getKey = (): Buffer => {
|
||||||
|
const secret = process.env.API_KEY_ENCRYPTION_SECRET ?? process.env.JWT_SECRET ?? "wallmuse-dev-secret";
|
||||||
|
return createHash("sha256").update(secret).digest();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hashPassword = (password: string): Promise<string> => bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
export const verifyPassword = (password: string, hash: string): Promise<boolean> =>
|
||||||
|
bcrypt.compare(password, hash);
|
||||||
|
|
||||||
|
export const encryptApiKey = (plainText: string): string => {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv(algorithm, getKey(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return [iv, tag, encrypted].map((part) => part.toString("base64url")).join(".");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptApiKey = (payload: string): string => {
|
||||||
|
const [ivText, tagText, encryptedText] = payload.split(".");
|
||||||
|
if (!ivText || !tagText || !encryptedText) {
|
||||||
|
throw new Error("Invalid encrypted API key payload");
|
||||||
|
}
|
||||||
|
const decipher = createDecipheriv(algorithm, getKey(), Buffer.from(ivText, "base64url"));
|
||||||
|
decipher.setAuthTag(Buffer.from(tagText, "base64url"));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encryptedText, "base64url")),
|
||||||
|
decipher.final()
|
||||||
|
]).toString("utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maskApiKey = (apiKey: string): string => {
|
||||||
|
if (apiKey.length <= 10) {
|
||||||
|
return `${apiKey.slice(0, 2)}****${apiKey.slice(-2)}`;
|
||||||
|
}
|
||||||
|
return `${apiKey.slice(0, 6)}****${apiKey.slice(-4)}`;
|
||||||
|
};
|
||||||
53
apps/api/src/server.ts
Executable file
53
apps/api/src/server.ts
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import jwt from "@fastify/jwt";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { JsonWallMuseDb } from "@wallmuse/db";
|
||||||
|
import { ApiError, sendError } from "./errors.js";
|
||||||
|
import { registerRoutes } from "./routes.js";
|
||||||
|
|
||||||
|
export const buildServer = async () => {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: process.env.LOG_LEVEL ?? "info"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = JsonWallMuseDb.fromEnv();
|
||||||
|
await db.init();
|
||||||
|
app.decorate("db", db);
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
credentials: true
|
||||||
|
});
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(jwt, {
|
||||||
|
secret: process.env.JWT_SECRET ?? "wallmuse-dev-jwt-secret"
|
||||||
|
});
|
||||||
|
await app.register(sensible);
|
||||||
|
|
||||||
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
sendError(reply, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("validation" in error) {
|
||||||
|
sendError(reply, new ApiError(400, "VALIDATION_ERROR", error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.log.error(error);
|
||||||
|
sendError(reply, new ApiError(500, "INTERNAL_ERROR", "Internal server error"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerRoutes(app);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const app = await buildServer();
|
||||||
|
const host = process.env.API_HOST ?? "0.0.0.0";
|
||||||
|
const port = Number(process.env.API_PORT ?? "4000");
|
||||||
|
await app.listen({ host, port });
|
||||||
|
}
|
||||||
94
apps/api/src/smoke-test.ts
Executable file
94
apps/api/src/smoke-test.ts
Executable file
@@ -0,0 +1,94 @@
|
|||||||
|
import { buildServer } from "./server.js";
|
||||||
|
|
||||||
|
const app = await buildServer();
|
||||||
|
|
||||||
|
const request = async (
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
body?: unknown,
|
||||||
|
token?: string
|
||||||
|
): Promise<{ statusCode: number; json: any }> => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
payload: body
|
||||||
|
});
|
||||||
|
let json: any;
|
||||||
|
try {
|
||||||
|
json = response.json();
|
||||||
|
} catch {
|
||||||
|
json = response.body;
|
||||||
|
}
|
||||||
|
if (response.statusCode >= 400) {
|
||||||
|
throw new Error(`${method} ${url} failed: ${response.statusCode} ${response.body}`);
|
||||||
|
}
|
||||||
|
return { statusCode: response.statusCode, json };
|
||||||
|
};
|
||||||
|
|
||||||
|
const suffix = Date.now();
|
||||||
|
await request("GET", "/api/v1/health");
|
||||||
|
const config = await request("GET", "/api/v1/app/config");
|
||||||
|
const register = await request("POST", "/api/v1/auth/register", {
|
||||||
|
email: `api-smoke-${suffix}@wallmuse.local`,
|
||||||
|
password: "password123",
|
||||||
|
name: "API Smoke"
|
||||||
|
});
|
||||||
|
const token = register.json.token as string;
|
||||||
|
const me = await request("GET", "/api/v1/me", undefined, token);
|
||||||
|
const providers = await request("GET", "/api/v1/providers");
|
||||||
|
const providerId = providers.json[0].id as string;
|
||||||
|
const models = await request("GET", `/api/v1/models?providerId=${providerId}`);
|
||||||
|
const modelId = models.json[0].id as string;
|
||||||
|
const apiKey = await request(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/user-api-keys",
|
||||||
|
{
|
||||||
|
providerId,
|
||||||
|
name: "Smoke key",
|
||||||
|
apiKey: "sk-wallmuse-smoke-test-secret",
|
||||||
|
defaultModelId: modelId
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
if (apiKey.json.encryptedKey || apiKey.json.maskedKey.includes("secret")) {
|
||||||
|
throw new Error("API key response leaked secret material");
|
||||||
|
}
|
||||||
|
await request("POST", `/api/v1/user-api-keys/${apiKey.json.id}/test`, undefined, token);
|
||||||
|
const generation = await request(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/generations",
|
||||||
|
{
|
||||||
|
mode: "text_to_image",
|
||||||
|
prompt: "futuristic city at sunrise, clean wallpaper composition",
|
||||||
|
modelId,
|
||||||
|
aspectRatios: ["16:9", "9:16"],
|
||||||
|
resolution: "2k",
|
||||||
|
quality: "standard",
|
||||||
|
batchSize: 1,
|
||||||
|
publishToGallery: false
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
await request("GET", `/api/v1/generation-groups/${generation.json.generationGroup.id}`, undefined, token);
|
||||||
|
await request("GET", "/api/v1/admin/tasks", undefined, token);
|
||||||
|
await request("GET", "/api/v1/admin/provider-logs", undefined, token);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
siteName: config.json.site.name,
|
||||||
|
user: me.json.user.email,
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
generationGroupId: generation.json.generationGroup.id
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
14
apps/api/tsconfig.json
Executable file
14
apps/api/tsconfig.json
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../packages/shared" },
|
||||||
|
{ "path": "../../packages/db" },
|
||||||
|
{ "path": "../../packages/api-client" }
|
||||||
|
],
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
5
apps/web/next-env.d.ts
vendored
Executable file
5
apps/web/next-env.d.ts
vendored
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
9
apps/web/next.config.mjs
Executable file
9
apps/web/next.config.mjs
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ["@wallmuse/api-client", "@wallmuse/shared", "@wallmuse/ui-tokens"],
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ["lucide-react"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
27
apps/web/package.json
Executable file
27
apps/web/package.json
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -H 0.0.0.0 -p 3100",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -H 0.0.0.0 -p 3100",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wallmuse/api-client": "workspace:*",
|
||||||
|
"@wallmuse/shared": "workspace:*",
|
||||||
|
"@wallmuse/ui-tokens": "workspace:*",
|
||||||
|
"lucide-react": "0.511.0",
|
||||||
|
"next": "15.3.3",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.15.30",
|
||||||
|
"@types/react": "19.1.6",
|
||||||
|
"@types/react-dom": "19.1.5",
|
||||||
|
"typescript": "5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
180
apps/web/src/app/generate/page.tsx
Executable file
180
apps/web/src/app/generate/page.tsx
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
import { CheckCircle2, Clock, ImagePlus, Monitor, RefreshCw, Smartphone, Sparkles } from "lucide-react";
|
||||||
|
import { wallMuseApi } from "@wallmuse/api-client";
|
||||||
|
import { AppShell } from "../../components/app-shell";
|
||||||
|
|
||||||
|
export default async function GeneratePage() {
|
||||||
|
const history = await wallMuseApi.listGenerationHistory();
|
||||||
|
const current = await wallMuseApi.createGeneration({
|
||||||
|
mode: "text_to_image",
|
||||||
|
prompt: "A blue glass canyon with a silent river and soft morning haze",
|
||||||
|
style: "Landscape",
|
||||||
|
resolution: "4k",
|
||||||
|
outputPair: true,
|
||||||
|
provider: "OpenAI Compatible",
|
||||||
|
model: "gpt-image-1",
|
||||||
|
privateMode: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const desktop = current.assets.find((asset) => asset.label === "Desktop") ?? current.assets[0];
|
||||||
|
const mobile = current.assets.find((asset) => asset.label === "Mobile") ?? current.assets[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="page-grid">
|
||||||
|
<section className="page-title">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<Sparkles size={18} strokeWidth={1.75} />
|
||||||
|
Generation workspace
|
||||||
|
</p>
|
||||||
|
<h1>一次生成桌面和手机两种壁纸。</h1>
|
||||||
|
<p>参数面板、双规格预览和任务队列在桌面端并排展示,移动端自动收拢为单列。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="form-layout">
|
||||||
|
<form className="glass-panel panel-pad">
|
||||||
|
<div className="segmented" aria-label="Generation mode">
|
||||||
|
<button className="active" type="button">文生图</button>
|
||||||
|
<button type="button">图生图</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field" style={{ marginTop: 16 }}>
|
||||||
|
<label htmlFor="prompt">Prompt</label>
|
||||||
|
<textarea
|
||||||
|
id="prompt"
|
||||||
|
defaultValue="A luminous blue glass canyon with a quiet river, soft morning haze, premium wallpaper, coherent desktop and mobile composition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="negative">Negative prompt</label>
|
||||||
|
<textarea
|
||||||
|
id="negative"
|
||||||
|
defaultValue="low resolution, watermark, text, distorted architecture, noisy texture"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="style">Style</label>
|
||||||
|
<select id="style" defaultValue="Landscape">
|
||||||
|
<option>Landscape</option>
|
||||||
|
<option>Nature</option>
|
||||||
|
<option>Minimal</option>
|
||||||
|
<option>Space</option>
|
||||||
|
<option>Cyberpunk</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Resolution</label>
|
||||||
|
<div className="segmented">
|
||||||
|
<button type="button">1K</button>
|
||||||
|
<button className="active" type="button">2K</button>
|
||||||
|
<button type="button">4K</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>同时生成横竖屏</strong>
|
||||||
|
<br />
|
||||||
|
<small>16:9 desktop + 9:16 mobile</small>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" defaultChecked aria-label="Output desktop and mobile pair" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>私密生成</strong>
|
||||||
|
<br />
|
||||||
|
<small>不进入公开图库审核队列</small>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" aria-label="Private generation" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="provider">Provider</label>
|
||||||
|
<select id="provider" defaultValue="OpenAI Compatible">
|
||||||
|
<option>OpenAI Compatible</option>
|
||||||
|
<option>Seedream</option>
|
||||||
|
<option>Qwen Image</option>
|
||||||
|
<option>SiliconFlow</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="primary-button" type="button" style={{ width: "100%" }}>
|
||||||
|
<Sparkles size={20} strokeWidth={1.75} />
|
||||||
|
Generate wallpapers
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="glass-panel panel-pad">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>结果预览</h2>
|
||||||
|
<p>同组资产展示一致性评分和单张重试入口。</p>
|
||||||
|
</div>
|
||||||
|
<span className="score">
|
||||||
|
<CheckCircle2 size={18} strokeWidth={1.75} />
|
||||||
|
{current.consistencyScore}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="result-pair">
|
||||||
|
<figure className="result-frame landscape">
|
||||||
|
<img src={desktop.imageUrl} alt={`${current.prompt} desktop result`} />
|
||||||
|
</figure>
|
||||||
|
<figure className="result-frame portrait">
|
||||||
|
<img src={mobile.imageUrl} alt={`${current.prompt} mobile result`} />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quick-specs">
|
||||||
|
<span className="pill">
|
||||||
|
<Monitor size={16} strokeWidth={1.75} />
|
||||||
|
{desktop.width}x{desktop.height}
|
||||||
|
</span>
|
||||||
|
<span className="pill">
|
||||||
|
<Smartphone size={16} strokeWidth={1.75} />
|
||||||
|
{mobile.width}x{mobile.height}
|
||||||
|
</span>
|
||||||
|
<button className="glass-button">
|
||||||
|
<RefreshCw size={16} strokeWidth={1.75} />
|
||||||
|
重试单张
|
||||||
|
</button>
|
||||||
|
<button className="glass-button">
|
||||||
|
<ImagePlus size={16} strokeWidth={1.75} />
|
||||||
|
发布到图库
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="glass-panel panel-pad">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>任务队列</h2>
|
||||||
|
<p>Mock API 当前返回已完成任务。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-list">
|
||||||
|
{[current, ...history].map((item) => (
|
||||||
|
<div className="task-item" key={item.id}>
|
||||||
|
<strong>{item.style} · {item.resolution}</strong>
|
||||||
|
<span>{item.prompt}</span>
|
||||||
|
<div className="status-line">
|
||||||
|
<span className="score">
|
||||||
|
<CheckCircle2 size={16} strokeWidth={1.75} />
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Clock size={14} strokeWidth={1.75} /> {new Date(item.createdAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
801
apps/web/src/app/globals.css
Executable file
801
apps/web/src/app/globals.css
Executable file
@@ -0,0 +1,801 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.72), transparent 34%),
|
||||||
|
linear-gradient(145deg, var(--color-bg-page), #d9ecff 45%, var(--color-bg-page));
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] body {
|
||||||
|
background:
|
||||||
|
linear-gradient(140deg, rgba(102, 170, 255, 0.12), transparent 38%),
|
||||||
|
linear-gradient(145deg, var(--color-bg-page), #0c1b30 58%, #07111f);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stage {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: min(1800px, 100%);
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
box-shadow: var(--shadow-shell);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(280px, 660px) auto;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 18px 34px;
|
||||||
|
border-bottom: 1px solid var(--color-border-soft);
|
||||||
|
background: var(--color-bg-surface-strong);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--font-xl);
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 5px);
|
||||||
|
gap: 3px;
|
||||||
|
align-items: end;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.68), rgba(47, 134, 255, 0.14));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .brand-mark {
|
||||||
|
background: linear-gradient(145deg, rgba(102, 170, 255, 0.2), rgba(8, 18, 32, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span {
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: linear-gradient(180deg, #9ed3ff, var(--color-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span:nth-child(1) {
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span:nth-child(2) {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span:nth-child(3) {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark span:nth-child(4) {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 52px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box kbd {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button,
|
||||||
|
.glass-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 44px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button {
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover,
|
||||||
|
.glass-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 48px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0 20px;
|
||||||
|
background: linear-gradient(135deg, #2f86ff, #66aaff);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 760;
|
||||||
|
box-shadow: 0 14px 30px rgba(47, 134, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-content {
|
||||||
|
padding: 30px 34px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(340px, 0.9fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-composer {
|
||||||
|
min-height: 326px;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-composer h1,
|
||||||
|
.page-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 780px;
|
||||||
|
font-size: var(--font-3xl);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-composer p,
|
||||||
|
.page-title p {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
max-width: 720px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-bar textarea,
|
||||||
|
.field textarea,
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-surface-strong);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-bar textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 15px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-specs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-preview {
|
||||||
|
position: relative;
|
||||||
|
min-height: 326px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 326px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metadata {
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
left: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(4, 10, 18, 0.48);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-rail {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 2px 0 8px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill.active {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
background: var(--color-bg-control-active);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 260px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card:hover,
|
||||||
|
.wallpaper-card:focus-within {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
box-shadow: var(--shadow-floating);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card:hover img {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card.portrait {
|
||||||
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card.portrait img,
|
||||||
|
.wallpaper-card.portrait {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badge,
|
||||||
|
.card-more,
|
||||||
|
.card-stats,
|
||||||
|
.card-actions {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badge {
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
color: #172235;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: 850;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-more {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stats {
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(4, 10, 18, 0.52);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -44%);
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card:hover .card-actions,
|
||||||
|
.wallpaper-card:focus-within .card-actions {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-action {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr) minmax(280px, 340px);
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-pad {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 132px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 13px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.active {
|
||||||
|
background: var(--color-bg-control-active);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(180px, 0.75fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-frame {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-frame.landscape {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-frame.portrait {
|
||||||
|
aspect-ratio: 9 / 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-frame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 560px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 460px) minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-border-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-note {
|
||||||
|
margin-top: 24px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
.wallpaper-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
.hero-strip,
|
||||||
|
.form-layout,
|
||||||
|
.auth-layout,
|
||||||
|
.settings-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-layout {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.page-stage {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav .search-box {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-content {
|
||||||
|
padding: 18px 16px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions .glass-button span,
|
||||||
|
.search-box kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-composer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-composer h1,
|
||||||
|
.page-title h1 {
|
||||||
|
font-size: var(--font-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-bar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-card.portrait,
|
||||||
|
.wallpaper-card.portrait img {
|
||||||
|
min-height: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-pair {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.brand {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-composer h1,
|
||||||
|
.page-title h1 {
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metadata,
|
||||||
|
.section-heading,
|
||||||
|
.status-line {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art,
|
||||||
|
.auth-art img {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/web/src/app/layout.tsx
Executable file
40
apps/web/src/app/layout.tsx
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import "@wallmuse/ui-tokens/src/tokens.css";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "WallMuse",
|
||||||
|
description: "AI wallpaper generation and gallery for desktop and mobile pairs."
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
colorScheme: "light dark"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('wallmuse-theme') || 'system';
|
||||||
|
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var theme = saved === 'system' ? (systemDark ? 'dark' : 'light') : saved;
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
} catch (error) {
|
||||||
|
document.documentElement.dataset.theme = 'light';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/web/src/app/login/page.tsx
Executable file
54
apps/web/src/app/login/page.tsx
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { KeyRound, LogIn, ShieldCheck } from "lucide-react";
|
||||||
|
import { AppShell } from "../../components/app-shell";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="auth-layout">
|
||||||
|
<div className="glass-panel auth-art">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=1500&h=1100&q=86"
|
||||||
|
alt="Blue canyon wallpaper preview"
|
||||||
|
/>
|
||||||
|
<div className="preview-metadata">
|
||||||
|
<div>
|
||||||
|
<strong>WallMuse account</strong>
|
||||||
|
<div>生成历史、收藏、API Key 和下载记录会同步到账号。</div>
|
||||||
|
</div>
|
||||||
|
<ShieldCheck size={22} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="glass-panel panel-pad">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<LogIn size={18} strokeWidth={1.75} />
|
||||||
|
Sign in
|
||||||
|
</p>
|
||||||
|
<h1 style={{ margin: 0 }}>登录 WallMuse</h1>
|
||||||
|
<p className="footer-note">前台通过 packages/api-client 调用认证接口,后端接入后可直接替换 mock 响应。</p>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input id="email" type="email" defaultValue="feng@example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input id="password" type="password" defaultValue="wallmuse-demo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="primary-button" type="button" style={{ width: "100%" }}>
|
||||||
|
<KeyRound size={18} strokeWidth={1.75} />
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="quick-specs">
|
||||||
|
<Link className="glass-button" href="/register">Create account</Link>
|
||||||
|
<Link className="glass-button" href="/settings/api-keys">API Key settings</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/web/src/app/page.tsx
Executable file
86
apps/web/src/app/page.tsx
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Download, Monitor, ShieldCheck, Smartphone, Sparkles } from "lucide-react";
|
||||||
|
import { wallMuseApi } from "@wallmuse/api-client";
|
||||||
|
import { AppShell } from "../components/app-shell";
|
||||||
|
import { CategoryRail } from "../components/category-rail";
|
||||||
|
import { WallpaperGrid } from "../components/wallpaper-grid";
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const wallpapers = await wallMuseApi.listWallpapers();
|
||||||
|
const featured = wallpapers.find((item) => item.featured) ?? wallpapers[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="page-grid">
|
||||||
|
<section className="hero-strip">
|
||||||
|
<div className="glass-panel hero-composer">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<Sparkles size={18} strokeWidth={1.75} />
|
||||||
|
Desktop and mobile wallpaper pairs
|
||||||
|
</p>
|
||||||
|
<h1>一次创意输入,同时得到桌面版与手机版高清壁纸。</h1>
|
||||||
|
<p>
|
||||||
|
WallMuse 使用同一主题、主体、色彩与光照约束,生成 16:9 和 9:16
|
||||||
|
两种设备规格,适合桌面、锁屏和移动端图库。
|
||||||
|
</p>
|
||||||
|
<div className="prompt-bar">
|
||||||
|
<textarea
|
||||||
|
aria-label="Quick prompt"
|
||||||
|
placeholder="例如:蓝色玻璃峡谷、清晨薄雾、柔和体积光、适合桌面和手机锁屏..."
|
||||||
|
defaultValue="A luminous blue glass canyon with a quiet river, soft morning haze, cinematic wallpaper"
|
||||||
|
/>
|
||||||
|
<Link className="primary-button" href="/generate">
|
||||||
|
<Sparkles size={20} strokeWidth={1.75} />
|
||||||
|
生成壁纸
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="quick-specs">
|
||||||
|
<span className="pill">
|
||||||
|
<Monitor size={16} strokeWidth={1.75} />
|
||||||
|
16:9 Desktop
|
||||||
|
</span>
|
||||||
|
<span className="pill">
|
||||||
|
<Smartphone size={16} strokeWidth={1.75} />
|
||||||
|
9:16 Mobile
|
||||||
|
</span>
|
||||||
|
<span className="pill">
|
||||||
|
<ShieldCheck size={16} strokeWidth={1.75} />
|
||||||
|
Server-side API keys
|
||||||
|
</span>
|
||||||
|
<span className="pill">
|
||||||
|
<Download size={16} strokeWidth={1.75} />
|
||||||
|
1K / 2K / 4K
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass-panel feature-preview">
|
||||||
|
<img src={featured.imageUrl} alt={featured.prompt} />
|
||||||
|
<div className="preview-metadata">
|
||||||
|
<div>
|
||||||
|
<strong>{featured.title}</strong>
|
||||||
|
<div>{featured.style} · {featured.model}</div>
|
||||||
|
</div>
|
||||||
|
<div>{featured.resolution.toUpperCase()} · {featured.ratio}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CategoryRail />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>精选图库</h2>
|
||||||
|
<p>大图优先、轻量信息、悬浮收藏与下载操作。</p>
|
||||||
|
</div>
|
||||||
|
<Link className="glass-button" href="/generate">
|
||||||
|
<Sparkles size={18} strokeWidth={1.75} />
|
||||||
|
Create pair
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<WallpaperGrid wallpapers={wallpapers} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/web/src/app/register/page.tsx
Executable file
48
apps/web/src/app/register/page.tsx
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { AppShell } from "../../components/app-shell";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="auth-layout">
|
||||||
|
<div className="glass-panel auth-art">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1446776811953-b23d57bd21aa?auto=format&fit=crop&w=1500&h=1100&q=86"
|
||||||
|
alt="Orbital wallpaper preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="glass-panel panel-pad">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<UserPlus size={18} strokeWidth={1.75} />
|
||||||
|
Create account
|
||||||
|
</p>
|
||||||
|
<h1 style={{ margin: 0 }}>注册 WallMuse</h1>
|
||||||
|
<p className="footer-note">注册后可以保存自带模型 API Key、生成历史和收藏图库。</p>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="name">Name</label>
|
||||||
|
<input id="name" defaultValue="Feng Lee" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input id="email" type="email" defaultValue="feng@example.com" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input id="password" type="password" defaultValue="wallmuse-demo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="primary-button" type="button" style={{ width: "100%" }}>
|
||||||
|
<UserPlus size={18} strokeWidth={1.75} />
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
<div className="quick-specs">
|
||||||
|
<Link className="glass-button" href="/login">Already have an account</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/web/src/app/results/[id]/page.tsx
Executable file
69
apps/web/src/app/results/[id]/page.tsx
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Download, Heart, Monitor, RefreshCw, Share2, Smartphone } from "lucide-react";
|
||||||
|
import { wallMuseApi } from "@wallmuse/api-client";
|
||||||
|
import { AppShell } from "../../../components/app-shell";
|
||||||
|
|
||||||
|
export default async function ResultPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const group = await wallMuseApi.getGenerationGroup(id);
|
||||||
|
const desktop = group.assets.find((asset) => asset.label === "Desktop") ?? group.assets[0];
|
||||||
|
const mobile = group.assets.find((asset) => asset.label === "Mobile") ?? group.assets[1] ?? desktop;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="page-grid">
|
||||||
|
<section className="page-title">
|
||||||
|
<p className="eyebrow">Result detail</p>
|
||||||
|
<h1>{group.style} wallpaper pair</h1>
|
||||||
|
<p>{group.prompt}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-panel panel-pad">
|
||||||
|
<div className="result-pair">
|
||||||
|
<figure className="result-frame landscape">
|
||||||
|
<img src={desktop.imageUrl} alt={`${group.prompt} desktop wallpaper`} />
|
||||||
|
</figure>
|
||||||
|
<figure className="result-frame portrait">
|
||||||
|
<img src={mobile.imageUrl} alt={`${group.prompt} mobile wallpaper`} />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quick-specs">
|
||||||
|
<span className="pill">
|
||||||
|
<Monitor size={16} strokeWidth={1.75} />
|
||||||
|
Desktop {desktop.width}x{desktop.height}
|
||||||
|
</span>
|
||||||
|
<span className="pill">
|
||||||
|
<Smartphone size={16} strokeWidth={1.75} />
|
||||||
|
Mobile {mobile.width}x{mobile.height}
|
||||||
|
</span>
|
||||||
|
<span className="pill">Model {group.model}</span>
|
||||||
|
<span className="pill">Consistency {group.consistencyScore}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quick-specs">
|
||||||
|
<button className="primary-button">
|
||||||
|
<Download size={18} strokeWidth={1.75} />
|
||||||
|
Download pair
|
||||||
|
</button>
|
||||||
|
<button className="glass-button">
|
||||||
|
<Heart size={18} strokeWidth={1.75} />
|
||||||
|
Favorite
|
||||||
|
</button>
|
||||||
|
<button className="glass-button">
|
||||||
|
<Share2 size={18} strokeWidth={1.75} />
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
<button className="glass-button">
|
||||||
|
<RefreshCw size={18} strokeWidth={1.75} />
|
||||||
|
Retry group
|
||||||
|
</button>
|
||||||
|
<Link className="glass-button" href="/generate">
|
||||||
|
Generate again
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/web/src/app/settings/api-keys/page.tsx
Executable file
108
apps/web/src/app/settings/api-keys/page.tsx
Executable file
@@ -0,0 +1,108 @@
|
|||||||
|
import { CheckCircle2, Eye, KeyRound, PlugZap, ShieldCheck } from "lucide-react";
|
||||||
|
import { wallMuseApi } from "@wallmuse/api-client";
|
||||||
|
import { AppShell } from "../../../components/app-shell";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage() {
|
||||||
|
const keys = await wallMuseApi.listApiKeys();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="page-grid">
|
||||||
|
<section className="page-title">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<KeyRound size={18} strokeWidth={1.75} />
|
||||||
|
User API Key
|
||||||
|
</p>
|
||||||
|
<h1>绑定自己的模型 API Key。</h1>
|
||||||
|
<p>前端只提交到本站 API。Key 的加密保存、连通性测试和调用日志由后端接口处理。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-layout">
|
||||||
|
<form className="glass-panel panel-pad">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="provider">Provider</label>
|
||||||
|
<select id="provider" defaultValue="OpenAI Compatible">
|
||||||
|
<option>OpenAI Compatible</option>
|
||||||
|
<option>SiliconFlow</option>
|
||||||
|
<option>Qwen Image</option>
|
||||||
|
<option>Seedream</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="baseUrl">Base URL</label>
|
||||||
|
<input id="baseUrl" defaultValue="https://api.example.com/v1" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="apiKey">API Key</label>
|
||||||
|
<input id="apiKey" type="password" defaultValue="sk-wallmuse-demo-key" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="model">Model ID</label>
|
||||||
|
<input id="model" defaultValue="gpt-image-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>保存到账户</strong>
|
||||||
|
<br />
|
||||||
|
<small>服务端加密保存,前端不读取明文。</small>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" defaultChecked aria-label="Save key to account" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>设为默认生成模型</strong>
|
||||||
|
<br />
|
||||||
|
<small>生成页默认选择此供应商和模型。</small>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" defaultChecked aria-label="Set as default key" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quick-specs">
|
||||||
|
<button className="primary-button" type="button">
|
||||||
|
<ShieldCheck size={18} strokeWidth={1.75} />
|
||||||
|
Save key
|
||||||
|
</button>
|
||||||
|
<button className="glass-button" type="button">
|
||||||
|
<PlugZap size={18} strokeWidth={1.75} />
|
||||||
|
Test connection
|
||||||
|
</button>
|
||||||
|
<button className="glass-button" type="button" aria-label="Show key">
|
||||||
|
<Eye size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="glass-panel panel-pad">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>已保存 Key</h2>
|
||||||
|
<p>只展示 masked key,不暴露完整密钥。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-list">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<div className="key-row" key={key.id}>
|
||||||
|
<div className="status-line">
|
||||||
|
<strong>{key.provider}</strong>
|
||||||
|
<span className="score">
|
||||||
|
<CheckCircle2 size={16} strokeWidth={1.75} />
|
||||||
|
{key.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>{key.baseUrl}</span>
|
||||||
|
<span>{key.model} · {key.maskedKey}</span>
|
||||||
|
<div className="quick-specs">
|
||||||
|
<span className="pill">{key.isDefault ? "Default" : "Optional"}</span>
|
||||||
|
<span className="pill">{new Date(key.updatedAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/web/src/components/app-shell.tsx
Executable file
86
apps/web/src/components/app-shell.tsx
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Bell, Image, KeyRound, Moon, Search, Settings, Sparkles, Sun, User } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ThemePreference } from "@wallmuse/shared";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/", label: "Gallery", icon: Image },
|
||||||
|
{ href: "/generate", label: "Generate", icon: Sparkles },
|
||||||
|
{ href: "/settings/api-keys", label: "API Keys", icon: KeyRound }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<ThemePreference>("system");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = (localStorage.getItem("wallmuse-theme") as ThemePreference | null) ?? "system";
|
||||||
|
setTheme(saved);
|
||||||
|
applyTheme(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ThemeIcon = useMemo(() => (theme === "dark" ? Moon : Sun), [theme]);
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
const next: ThemePreference = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
|
||||||
|
setTheme(next);
|
||||||
|
localStorage.setItem("wallmuse-theme", next);
|
||||||
|
applyTheme(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stage">
|
||||||
|
<main className="app-shell">
|
||||||
|
<nav className="top-nav" aria-label="WallMuse navigation">
|
||||||
|
<Link href="/" className="brand" aria-label="WallMuse home">
|
||||||
|
<span className="brand-mark" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
<span>WallMuse</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<label className="search-box">
|
||||||
|
<Search size={19} strokeWidth={1.75} />
|
||||||
|
<input placeholder="搜索壁纸、风格或关键词..." aria-label="搜索壁纸、风格或关键词" />
|
||||||
|
<kbd>Ctrl K</kbd>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="nav-actions">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link className="glass-button" href={item.href} key={item.href}>
|
||||||
|
<Icon size={18} strokeWidth={1.75} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button className="icon-button" aria-label={`Theme: ${theme}`} onClick={cycleTheme}>
|
||||||
|
<ThemeIcon size={19} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" aria-label="Notifications">
|
||||||
|
<Bell size={19} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<Link className="icon-button" href="/login" aria-label="Login">
|
||||||
|
<User size={19} strokeWidth={1.75} />
|
||||||
|
</Link>
|
||||||
|
<Link className="avatar" href="/settings/api-keys" aria-label="User settings">
|
||||||
|
<Settings size={18} strokeWidth={1.75} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className="shell-content">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: ThemePreference) {
|
||||||
|
const isSystemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
document.documentElement.dataset.theme =
|
||||||
|
theme === "system" ? (isSystemDark ? "dark" : "light") : theme;
|
||||||
|
}
|
||||||
28
apps/web/src/components/category-rail.tsx
Executable file
28
apps/web/src/components/category-rail.tsx
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
"All",
|
||||||
|
"4K Ultra HD",
|
||||||
|
"Nature",
|
||||||
|
"Landscape",
|
||||||
|
"Anime",
|
||||||
|
"Abstract",
|
||||||
|
"Minimal",
|
||||||
|
"Space",
|
||||||
|
"Architecture",
|
||||||
|
"Cyberpunk",
|
||||||
|
"Mobile",
|
||||||
|
"Desktop"
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CategoryRail() {
|
||||||
|
return (
|
||||||
|
<div className="category-rail" aria-label="Wallpaper categories">
|
||||||
|
{categories.map((category, index) => (
|
||||||
|
<button className={`category-pill ${index === 0 ? "active" : ""}`} key={category}>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/web/src/components/wallpaper-card.tsx
Executable file
31
apps/web/src/components/wallpaper-card.tsx
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Download, Heart, MoreHorizontal } from "lucide-react";
|
||||||
|
import type { WebWallpaper } from "@wallmuse/shared";
|
||||||
|
|
||||||
|
export function WallpaperCard({ wallpaper }: { wallpaper: WebWallpaper }) {
|
||||||
|
const isPortrait = wallpaper.ratio === "9:16";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`wallpaper-card ${isPortrait ? "portrait" : ""}`}>
|
||||||
|
<img src={wallpaper.imageUrl} alt={wallpaper.prompt} />
|
||||||
|
<span className="card-badge">{wallpaper.resolution.toUpperCase()} ULTRA HD</span>
|
||||||
|
<button className="icon-button card-more" aria-label={`More actions for ${wallpaper.title}`}>
|
||||||
|
<MoreHorizontal size={19} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button className="floating-action" aria-label={`Favorite ${wallpaper.title}`}>
|
||||||
|
<Heart size={22} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button className="floating-action" aria-label={`Download ${wallpaper.title}`}>
|
||||||
|
<Download size={22} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Link className="card-stats" href={`/results/${wallpaper.id}`}>
|
||||||
|
<span>{wallpaper.title}</span>
|
||||||
|
<span>
|
||||||
|
{wallpaper.likes.toLocaleString()} likes · {wallpaper.downloads.toLocaleString()} downloads
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/src/components/wallpaper-grid.tsx
Executable file
12
apps/web/src/components/wallpaper-grid.tsx
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { WebWallpaper } from "@wallmuse/shared";
|
||||||
|
import { WallpaperCard } from "./wallpaper-card";
|
||||||
|
|
||||||
|
export function WallpaperGrid({ wallpapers }: { wallpapers: WebWallpaper[] }) {
|
||||||
|
return (
|
||||||
|
<div className="wallpaper-grid">
|
||||||
|
{wallpapers.map((wallpaper) => (
|
||||||
|
<WallpaperCard wallpaper={wallpaper} key={wallpaper.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/tsconfig.json
Executable file
24
apps/web/tsconfig.json
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
8
apps/worker-generation/package.json
Normal file
8
apps/worker-generation/package.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/worker-generation",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": { "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit -p tsconfig.json", "build": "tsc -p tsconfig.json" },
|
||||||
|
"dependencies": { "@wallmuse/db": "workspace:*", "@wallmuse/image-pipeline": "workspace:*", "@wallmuse/provider-adapters": "workspace:*", "@wallmuse/shared": "workspace:*", "bullmq": "^5.53.2", "ioredis": "^5.6.1" }
|
||||||
|
}
|
||||||
20
apps/worker-generation/src/connection.ts
Normal file
20
apps/worker-generation/src/connection.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Queue } from "bullmq";
|
||||||
|
import IORedis from "ioredis";
|
||||||
|
import type { GenerationWorkerJobData } from "../../../packages/db/src/json-store.js";
|
||||||
|
const defaultGenerationQueueName = "generation.default";
|
||||||
|
|
||||||
|
export function createRedisConnection(): IORedis {
|
||||||
|
return new IORedis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379", { maxRetriesPerRequest: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGenerationQueue(connection = createRedisConnection()): Queue<GenerationWorkerJobData> {
|
||||||
|
return new Queue<GenerationWorkerJobData>(process.env.GENERATION_QUEUE_NAME ?? defaultGenerationQueueName, {
|
||||||
|
connection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: Number(process.env.GENERATION_JOB_ATTEMPTS ?? 3),
|
||||||
|
backoff: { type: "exponential", delay: Number(process.env.GENERATION_JOB_BACKOFF_MS ?? 1000) },
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 100
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
8
apps/worker-generation/src/index.ts
Normal file
8
apps/worker-generation/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createGenerationWorker } from "./worker.js";
|
||||||
|
const worker = createGenerationWorker();
|
||||||
|
worker.on("ready", () => console.log("[worker-generation] ready"));
|
||||||
|
worker.on("completed", (job) => console.log(`[worker-generation] completed job=${job.id}`));
|
||||||
|
worker.on("failed", (job, error) => console.error(`[worker-generation] failed job=${job?.id ?? "unknown"} ${error.message}`));
|
||||||
|
const shutdown = async () => { await worker.close(); process.exit(0); };
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
58
apps/worker-generation/src/processor.ts
Normal file
58
apps/worker-generation/src/processor.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { JsonWallMuseDb } from "../../../packages/db/src/json-store.js";
|
||||||
|
import type { GenerationWorkerJobData } from "../../../packages/db/src/json-store.js";
|
||||||
|
import { LocalProviderAssetStore } from "@wallmuse/image-pipeline";
|
||||||
|
import { getImageProviderAdapter } from "@wallmuse/provider-adapters";
|
||||||
|
import type { ImageGenerationResult } from "@wallmuse/provider-adapters";
|
||||||
|
import type { AssetKind } from "@wallmuse/shared";
|
||||||
|
import { getGenerationSteps, getTargetSize } from "./state-machine.js";
|
||||||
|
|
||||||
|
export class GenerationProcessor {
|
||||||
|
constructor(private readonly db = JsonWallMuseDb.fromEnv(), private readonly storage = new LocalProviderAssetStore()) {}
|
||||||
|
async process(job: { data: GenerationWorkerJobData; updateProgress(progress: number): Promise<void> }): Promise<void> {
|
||||||
|
const data = job.data;
|
||||||
|
await this.db.init();
|
||||||
|
const attempt = await this.db.incrementGenerationAttempt(data.groupId, data.taskId);
|
||||||
|
const adapter = getImageProviderAdapter(data.providerSlug);
|
||||||
|
let masterStorageUrl = data.referenceAssetUrl;
|
||||||
|
try {
|
||||||
|
for (const step of getGenerationSteps(data)) {
|
||||||
|
await this.db.markGenerationStatus(data.groupId, data.taskId, "running", step.taskStatus, step.progress);
|
||||||
|
await job.updateProgress(step.progress);
|
||||||
|
if (!step.assetKind) continue;
|
||||||
|
const stored = await this.runProviderStep(data, step.assetKind, attempt, masterStorageUrl, step.promptSuffix);
|
||||||
|
if (step.assetKind === "master") masterStorageUrl = stored.storageUrl;
|
||||||
|
}
|
||||||
|
await this.db.markGenerationStatus(data.groupId, data.taskId, "succeeded", "succeeded", 100);
|
||||||
|
await job.updateProgress(100);
|
||||||
|
} catch (error) {
|
||||||
|
const normalized = adapter.normalizeError(error);
|
||||||
|
await this.db.markGenerationFailure(data.groupId, data.taskId, data.retryAssetKind ? "partial_succeeded" : "failed", normalized.code, normalized.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async runProviderStep(data: GenerationWorkerJobData, assetKind: Extract<AssetKind, "master" | "landscape" | "portrait">, attempt: number, masterStorageUrl?: string, promptSuffix = "") {
|
||||||
|
const adapter = getImageProviderAdapter(data.providerSlug);
|
||||||
|
const callId = `pcl_${crypto.randomUUID()}`;
|
||||||
|
const callLog = await this.db.createProviderCallLog({ id: callId, ...(data.taskId === undefined ? {} : { taskId: data.taskId }), groupId: data.groupId, providerId: data.providerId, modelId: data.modelId, assetKind, attempt });
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const size = getTargetSize(data, assetKind);
|
||||||
|
const baseInput = { prompt: `${data.prompt}${promptSuffix}`, model: data.modelSlug, size, width: size.width, height: size.height, responseFormat: "base64" as const, ...(data.negativePrompt === undefined ? {} : { negativePrompt: data.negativePrompt }), ...(data.seed === undefined ? {} : { seed: data.seed }) };
|
||||||
|
const result = assetKind === "master" || !adapter.generateImageToImage ? await adapter.generateTextToImage(baseInput, { requestId: callId, metadata: { assetKind, groupId: data.groupId } }) : await adapter.generateImageToImage({ ...baseInput, images: [{ kind: "url", value: masterStorageUrl ?? data.referenceAssetUrl ?? "" }] }, { requestId: callId, metadata: { assetKind, groupId: data.groupId } });
|
||||||
|
const stored = await this.storeFirstAsset(data, assetKind, result);
|
||||||
|
await this.db.completeProviderCallLog(callLog.id, { status: "succeeded", latencyMs: Date.now() - startedAt, usage: result.usage, rawMetadata: result.rawMetadata });
|
||||||
|
return stored;
|
||||||
|
} catch (error) {
|
||||||
|
const normalized = adapter.normalizeError(error);
|
||||||
|
await this.db.completeProviderCallLog(callLog.id, { status: "failed", latencyMs: Date.now() - startedAt, errorCode: normalized.code, errorMessage: normalized.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async storeFirstAsset(data: GenerationWorkerJobData, assetKind: Extract<AssetKind, "master" | "landscape" | "portrait">, result: ImageGenerationResult) {
|
||||||
|
const asset = result.assets[0];
|
||||||
|
if (!asset) throw new Error(`Provider returned no assets for ${assetKind}`);
|
||||||
|
const stored = await this.storage.storeProviderAsset({ userId: data.userId, groupId: data.groupId, assetKind, source: asset });
|
||||||
|
await this.db.upsertGeneratedAsset({ groupId: data.groupId, ...(data.taskId === undefined ? {} : { taskId: data.taskId }), userId: data.userId, assetKind, status: "active", bucket: stored.bucket, objectKey: stored.objectKey, storageUrl: stored.storageUrl, publicUrl: stored.publicUrl, mimeType: stored.mimeType, byteSize: stored.byteSize, sha256: stored.sha256, providerId: data.providerId, modelId: data.modelId, ...(stored.width === undefined ? {} : { width: stored.width }), ...(stored.height === undefined ? {} : { height: stored.height }), ...(asset.seed === undefined ? {} : { seed: asset.seed }) });
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/worker-generation/src/state-machine.ts
Normal file
18
apps/worker-generation/src/state-machine.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { GenerationWorkerJobData } from "../../../packages/db/src/json-store.js";
|
||||||
|
import type { AssetKind, AspectRatio, GenerationTaskStatus } from "@wallmuse/shared";
|
||||||
|
|
||||||
|
export interface GenerationStep { taskStatus: GenerationTaskStatus; phase: string; assetKind?: Extract<AssetKind, "master" | "landscape" | "portrait">; progress: number; promptSuffix?: string; }
|
||||||
|
const fullPipeline: GenerationStep[] = [
|
||||||
|
{ phase: "validating", taskStatus: "dispatching", progress: 8 },
|
||||||
|
{ phase: "generating_master", taskStatus: "running", assetKind: "master", progress: 24 },
|
||||||
|
{ phase: "deriving_landscape", taskStatus: "running", assetKind: "landscape", progress: 48, promptSuffix: " Adapt this scene into a desktop 16:9 wallpaper while preserving subject, style, color palette, light and material." },
|
||||||
|
{ phase: "deriving_portrait", taskStatus: "running", assetKind: "portrait", progress: 70, promptSuffix: " Adapt this scene into a mobile 9:16 wallpaper while preserving subject, style, color palette, light and material." },
|
||||||
|
{ phase: "downloading", taskStatus: "uploading", progress: 86 },
|
||||||
|
{ phase: "processing", taskStatus: "post_processing", progress: 94 }
|
||||||
|
];
|
||||||
|
export function getGenerationSteps(job: GenerationWorkerJobData): GenerationStep[] { return job.retryAssetKind ? fullPipeline.filter((step) => !step.assetKind || step.assetKind === job.retryAssetKind || step.phase === "downloading" || step.phase === "processing") : fullPipeline; }
|
||||||
|
export function getTargetSize(job: GenerationWorkerJobData, assetKind: Extract<AssetKind, "master" | "landscape" | "portrait">): { aspectRatio: AspectRatio; resolution: "1k" | "2k" | "4k"; width: number; height: number } {
|
||||||
|
const presets = { "1k": { master: [1024, 1024], landscape: [1280, 720], portrait: [720, 1280] }, "2k": { master: [1536, 1536], landscape: [2560, 1440], portrait: [1440, 2560] }, "4k": { master: [2048, 2048], landscape: [3840, 2160], portrait: [2160, 3840] } } as const;
|
||||||
|
const [width, height] = presets[job.resolution][assetKind];
|
||||||
|
return { aspectRatio: assetKind === "portrait" ? "9:16" : assetKind === "landscape" ? "16:9" : "1:1", resolution: job.resolution, width, height };
|
||||||
|
}
|
||||||
10
apps/worker-generation/src/worker.ts
Normal file
10
apps/worker-generation/src/worker.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Worker } from "bullmq";
|
||||||
|
import type { GenerationWorkerJobData } from "../../../packages/db/src/json-store.js";
|
||||||
|
import { GenerationProcessor } from "./processor.js";
|
||||||
|
import { createRedisConnection } from "./connection.js";
|
||||||
|
const defaultGenerationQueueName = "generation.default";
|
||||||
|
export function createGenerationWorker() {
|
||||||
|
const processor = new GenerationProcessor();
|
||||||
|
const connection = createRedisConnection();
|
||||||
|
return new Worker<GenerationWorkerJobData>(process.env.GENERATION_QUEUE_NAME ?? defaultGenerationQueueName, (job) => processor.process(job), { connection, concurrency: Number(process.env.GENERATION_WORKER_CONCURRENCY ?? 2) });
|
||||||
|
}
|
||||||
5
apps/worker-generation/tsconfig.json
Normal file
5
apps/worker-generation/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "types": ["node"] },
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
25
docs/WALLMUSE_INFRA_STATUS.md
Executable file
25
docs/WALLMUSE_INFRA_STATUS.md
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
# WallMuse Infra Status
|
||||||
|
|
||||||
|
Infra files in this app are owned by the WallMuse-Infra workstream. Do not remove `infra/` or `scripts/health-check.sh` from other workstreams.
|
||||||
|
|
||||||
|
Completed files:
|
||||||
|
|
||||||
|
- `infra/docker-compose.yml`
|
||||||
|
- `infra/minio/create-buckets.sh`
|
||||||
|
- `infra/nginx/wallmuse.dev.conf`
|
||||||
|
- `infra/postgres/README.md`
|
||||||
|
- `scripts/health-check.sh`
|
||||||
|
|
||||||
|
Current external blocker: this server cannot currently resolve/reach apt and Docker registry hosts, so Node/npm/pnpm installation and Docker image pulls cannot complete until server DNS/network is fixed.
|
||||||
|
|
||||||
|
After network is fixed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/wallmuse/app
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nodejs npm
|
||||||
|
npm install -g pnpm@10.12.1
|
||||||
|
pnpm install
|
||||||
|
docker compose -f infra/docker-compose.yml --env-file .env up -d
|
||||||
|
bash scripts/health-check.sh
|
||||||
|
```
|
||||||
78
infra/docker-compose.yml
Executable file
78
infra/docker-compose.yml
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
name: wallmuse
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: wallmuse-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-wallmuse}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-wallmuse}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wallmuse_dev_password}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- wallmuse-postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wallmuse} -d ${POSTGRES_DB:-wallmuse}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8-alpine
|
||||||
|
container_name: wallmuse-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- wallmuse-redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
container_name: wallmuse-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-wallmuse}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-wallmuse_minio_password}
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- wallmuse-minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
container_name: wallmuse-minio-init
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "/scripts/create-buckets.sh"]
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-wallmuse}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-wallmuse_minio_password}
|
||||||
|
S3_BUCKET_ORIGINALS: ${S3_BUCKET_ORIGINALS:-wallpaper-originals}
|
||||||
|
S3_BUCKET_PREVIEWS: ${S3_BUCKET_PREVIEWS:-wallpaper-previews}
|
||||||
|
S3_BUCKET_DOWNLOADS: ${S3_BUCKET_DOWNLOADS:-wallpaper-downloads}
|
||||||
|
S3_BUCKET_REFERENCES: ${S3_BUCKET_REFERENCES:-wallpaper-references}
|
||||||
|
S3_BUCKET_TEMP: ${S3_BUCKET_TEMP:-wallpaper-temp}
|
||||||
|
volumes:
|
||||||
|
- ./minio/create-buckets.sh:/scripts/create-buckets.sh:ro
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
wallmuse-postgres-data:
|
||||||
|
wallmuse-redis-data:
|
||||||
|
wallmuse-minio-data:
|
||||||
16
infra/minio/create-buckets.sh
Executable file
16
infra/minio/create-buckets.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
mc alias set wallmuse http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
|
||||||
|
|
||||||
|
for bucket in \
|
||||||
|
"$S3_BUCKET_ORIGINALS" \
|
||||||
|
"$S3_BUCKET_PREVIEWS" \
|
||||||
|
"$S3_BUCKET_DOWNLOADS" \
|
||||||
|
"$S3_BUCKET_REFERENCES" \
|
||||||
|
"$S3_BUCKET_TEMP"
|
||||||
|
do
|
||||||
|
mc mb --ignore-existing "wallmuse/$bucket"
|
||||||
|
done
|
||||||
|
|
||||||
|
mc ls wallmuse
|
||||||
28
infra/nginx/wallmuse.dev.conf
Executable file
28
infra/nginx/wallmuse.dev.conf
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name wallmuse.local;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /admin/ {
|
||||||
|
proxy_pass http://127.0.0.1:3100/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:4000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
infra/postgres/README.md
Executable file
9
infra/postgres/README.md
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
# PostgreSQL
|
||||||
|
|
||||||
|
The development PostgreSQL instance is managed by `infra/docker-compose.yml`.
|
||||||
|
|
||||||
|
Default connection:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
postgresql://wallmuse:wallmuse_dev_password@localhost:5432/wallmuse
|
||||||
|
```
|
||||||
51
package.json
Executable file
51
package.json
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "wallmuse",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.15.4",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --parallel --filter @wallmuse/api dev",
|
||||||
|
"dev:web": "pnpm --filter @wallmuse/web dev",
|
||||||
|
"build:web": "pnpm --filter @wallmuse/web build",
|
||||||
|
"start:web": "pnpm --filter @wallmuse/web start",
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"test:image-pipeline": "tsx packages/image-pipeline/test/process-fixture.ts",
|
||||||
|
"infra:up": "docker compose -f infra/docker-compose.yml up -d",
|
||||||
|
"infra:down": "docker compose -f infra/docker-compose.yml down",
|
||||||
|
"health": "bash scripts/health-check.sh",
|
||||||
|
"dev:worker-generation": "pnpm --filter @wallmuse/worker-generation dev",
|
||||||
|
"test:mock-generation": "tsx scripts/run-mock-generation.ts"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.812.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/cors": "^11.0.1",
|
||||||
|
"@fastify/jwt": "^9.1.0",
|
||||||
|
"@fastify/sensible": "^6.0.3",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"bullmq": "^5.53.2",
|
||||||
|
"fastify": "^5.3.3",
|
||||||
|
"file-type": "^19.6.0",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
|
"sharp": "^0.34.1",
|
||||||
|
"zod": "^3.24.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/archiver": "^6.0.3",
|
||||||
|
"@types/node": "^22.15.17",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/api-client/package.json
Executable file
22
packages/api-client/package.json
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/api-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"build": "tsc -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wallmuse/shared": "workspace:*",
|
||||||
|
"zod": "^3.24.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
390
packages/api-client/src/index.ts
Executable file
390
packages/api-client/src/index.ts
Executable file
@@ -0,0 +1,390 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
apiPaths,
|
||||||
|
AppConfigResponseSchema,
|
||||||
|
CreateGenerationRequestSchema,
|
||||||
|
CreateGenerationResponseSchema,
|
||||||
|
GenerationGroupSchema,
|
||||||
|
ModelSummarySchema,
|
||||||
|
ProviderSummarySchema,
|
||||||
|
WebGenerationGroupSchema,
|
||||||
|
WebUserApiKeySchema,
|
||||||
|
WebUserSchema,
|
||||||
|
WebWallpaperSchema,
|
||||||
|
type CreateGenerationRequest,
|
||||||
|
type WebCreateGenerationInput,
|
||||||
|
type WebGenerationGroup,
|
||||||
|
type WebSaveApiKeyInput,
|
||||||
|
type WebUser,
|
||||||
|
type WebUserApiKey,
|
||||||
|
type WebWallpaper
|
||||||
|
} from "@wallmuse/shared";
|
||||||
|
|
||||||
|
export type WallMuseApiClientOptions = {
|
||||||
|
baseUrl?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WallMuseApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly body: unknown
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "WallMuseApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WallMuseApiClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly accessToken: string | undefined;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private readonly headers: HeadersInit | undefined;
|
||||||
|
|
||||||
|
constructor(options: WallMuseApiClientOptions = {}) {
|
||||||
|
this.baseUrl = options.baseUrl?.replace(/\/$/, "") ?? "";
|
||||||
|
this.accessToken = options.accessToken;
|
||||||
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
this.headers = options.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppConfig() {
|
||||||
|
return this.request(apiPaths.appConfig, AppConfigResponseSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviders() {
|
||||||
|
return this.request(apiPaths.providers, z.array(ProviderSummarySchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
getModels() {
|
||||||
|
return this.request(apiPaths.models, z.array(ModelSummarySchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
createGeneration(input: CreateGenerationRequest) {
|
||||||
|
const body = CreateGenerationRequestSchema.parse(input);
|
||||||
|
return this.request(apiPaths.generations, CreateGenerationResponseSchema, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getGenerationGroup(id: string) {
|
||||||
|
return this.request(apiPaths.generationGroup(id), GenerationGroupSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentUser(): Promise<WebUser> {
|
||||||
|
return this.request("/api/v1/me", WebUserSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
listWallpapers(): Promise<WebWallpaper[]> {
|
||||||
|
return this.request("/api/v1/wallpapers", z.array(WebWallpaperSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
listGenerationHistory(): Promise<WebGenerationGroup[]> {
|
||||||
|
return this.request("/api/v1/generation-groups", z.array(WebGenerationGroupSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
createWebGeneration(input: WebCreateGenerationInput): Promise<WebGenerationGroup> {
|
||||||
|
return this.request("/api/v1/generations", WebGenerationGroupSchema, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getWebGenerationGroup(id: string): Promise<WebGenerationGroup> {
|
||||||
|
return this.request(`/api/v1/generation-groups/${id}`, WebGenerationGroupSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
listApiKeys(): Promise<WebUserApiKey[]> {
|
||||||
|
return this.request("/api/v1/user-api-keys", z.array(WebUserApiKeySchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
saveApiKey(input: WebSaveApiKeyInput): Promise<WebUserApiKey> {
|
||||||
|
return this.request("/api/v1/user-api-keys", WebUserApiKeySchema, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<TSchema extends z.ZodTypeAny>(
|
||||||
|
path: string,
|
||||||
|
schema: TSchema,
|
||||||
|
init: RequestInit = {}
|
||||||
|
): Promise<z.output<TSchema>> {
|
||||||
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(this.accessToken ? { authorization: `Bearer ${this.accessToken}` } : {}),
|
||||||
|
...this.headers,
|
||||||
|
...init.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const body = text.length > 0 ? JSON.parse(text) : null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new WallMuseApiError(`WallMuse API request failed: ${response.status}`, response.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema.parse(body) as z.output<TSchema>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeEnv = {
|
||||||
|
process?: {
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPublicApiBaseUrl = (): string =>
|
||||||
|
(globalThis as RuntimeEnv).process?.env?.NEXT_PUBLIC_WALLMUSE_API_BASE ?? "";
|
||||||
|
|
||||||
|
const publicApiBaseUrl = getPublicApiBaseUrl();
|
||||||
|
|
||||||
|
const browserClient = new WallMuseApiClient({
|
||||||
|
baseUrl: publicApiBaseUrl,
|
||||||
|
fetchImpl: async (input, init) => {
|
||||||
|
if (!publicApiBaseUrl) {
|
||||||
|
return mockFetch(input, init);
|
||||||
|
}
|
||||||
|
return fetch(input, { ...init, credentials: "include" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const wallMuseApi = {
|
||||||
|
getCurrentUser: () => browserClient.getCurrentUser(),
|
||||||
|
listWallpapers: () => browserClient.listWallpapers(),
|
||||||
|
listGenerationHistory: () => browserClient.listGenerationHistory(),
|
||||||
|
createGeneration: (input: WebCreateGenerationInput) => browserClient.createWebGeneration(input),
|
||||||
|
getGenerationGroup: (id: string) => browserClient.getWebGenerationGroup(id),
|
||||||
|
listApiKeys: () => browserClient.listApiKeys(),
|
||||||
|
saveApiKey: (input: WebSaveApiKeyInput) => browserClient.saveApiKey(input)
|
||||||
|
};
|
||||||
|
|
||||||
|
const image = (id: string, width: number, height: number) =>
|
||||||
|
`https://images.unsplash.com/${id}?auto=format&fit=crop&w=${width}&h=${height}&q=86`;
|
||||||
|
|
||||||
|
const mockModelId = "00000000-0000-4000-8000-000000000101";
|
||||||
|
|
||||||
|
const mockUser: WebUser = {
|
||||||
|
id: "usr_demo",
|
||||||
|
name: "Feng Lee",
|
||||||
|
email: "feng@example.com",
|
||||||
|
avatarInitials: "FL",
|
||||||
|
theme: "system"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWallpapers: WebWallpaper[] = [
|
||||||
|
{
|
||||||
|
id: "wp-aurora",
|
||||||
|
title: "Aurora Glass Valley",
|
||||||
|
prompt: "A crystalline valley under soft aurora lights, cinematic wallpaper",
|
||||||
|
imageUrl: image("photo-1500530855697-b586d89ba3ee", 1200, 900),
|
||||||
|
ratio: "4:3",
|
||||||
|
resolution: "4k",
|
||||||
|
style: "Nature",
|
||||||
|
model: "Seedream",
|
||||||
|
likes: 1842,
|
||||||
|
downloads: 930,
|
||||||
|
colors: ["#9bd5ff", "#eaf7ff"],
|
||||||
|
createdAt: "2026-05-09T08:00:00.000Z",
|
||||||
|
featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wp-desktop-mist",
|
||||||
|
title: "Misty Desktop Lake",
|
||||||
|
prompt: "A calm alpine lake with glass reflections and airy blue mist",
|
||||||
|
imageUrl: image("photo-1501785888041-af3ef285b470", 1440, 810),
|
||||||
|
ratio: "16:9",
|
||||||
|
resolution: "4k",
|
||||||
|
style: "Landscape",
|
||||||
|
model: "Qwen Image",
|
||||||
|
likes: 1260,
|
||||||
|
downloads: 704,
|
||||||
|
colors: ["#d8ecff", "#607d9b"],
|
||||||
|
createdAt: "2026-05-09T08:10:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wp-mobile-neon",
|
||||||
|
title: "Blue Neon Alley",
|
||||||
|
prompt: "A rainy futuristic alley with low saturation blue neon",
|
||||||
|
imageUrl: image("photo-1519608487953-e999c86e7455", 900, 1600),
|
||||||
|
ratio: "9:16",
|
||||||
|
resolution: "2k",
|
||||||
|
style: "Cyberpunk",
|
||||||
|
model: "OpenAI Compatible",
|
||||||
|
likes: 902,
|
||||||
|
downloads: 388,
|
||||||
|
colors: ["#0e2a4e", "#66aaff"],
|
||||||
|
createdAt: "2026-05-09T08:20:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wp-minimal-wave",
|
||||||
|
title: "Minimal Ice Wave",
|
||||||
|
prompt: "Minimal translucent ice waves, soft studio light, clean wallpaper",
|
||||||
|
imageUrl: image("photo-1493246507139-91e8fad9978e", 1200, 900),
|
||||||
|
ratio: "4:3",
|
||||||
|
resolution: "2k",
|
||||||
|
style: "Minimal",
|
||||||
|
model: "FLUX",
|
||||||
|
likes: 740,
|
||||||
|
downloads: 284,
|
||||||
|
colors: ["#f4fbff", "#b5cbe2"],
|
||||||
|
createdAt: "2026-05-09T08:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wp-space-dawn",
|
||||||
|
title: "Orbital Dawn",
|
||||||
|
prompt: "A quiet orbital sunrise above a blue planet, premium wallpaper",
|
||||||
|
imageUrl: image("photo-1446776811953-b23d57bd21aa", 1440, 810),
|
||||||
|
ratio: "16:9",
|
||||||
|
resolution: "4k",
|
||||||
|
style: "Space",
|
||||||
|
model: "Seedream",
|
||||||
|
likes: 2014,
|
||||||
|
downloads: 1160,
|
||||||
|
colors: ["#07111f", "#66aaff"],
|
||||||
|
createdAt: "2026-05-09T08:40:00.000Z",
|
||||||
|
featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wp-architecture",
|
||||||
|
title: "Soft Concrete Atrium",
|
||||||
|
prompt: "Modern architectural atrium, cool daylight, gentle reflections",
|
||||||
|
imageUrl: image("photo-1497366754035-f200968a6e72", 1200, 900),
|
||||||
|
ratio: "4:3",
|
||||||
|
resolution: "2k",
|
||||||
|
style: "Architecture",
|
||||||
|
model: "Qwen Image",
|
||||||
|
likes: 580,
|
||||||
|
downloads: 210,
|
||||||
|
colors: ["#eef6ff", "#8293a8"],
|
||||||
|
createdAt: "2026-05-09T08:50:00.000Z"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockGenerationHistory: WebGenerationGroup[] = [
|
||||||
|
{
|
||||||
|
id: "gen-blue-canyon",
|
||||||
|
prompt: "A blue glass canyon with a silent river and soft morning haze",
|
||||||
|
status: "succeeded",
|
||||||
|
style: "Landscape",
|
||||||
|
model: "Seedream",
|
||||||
|
resolution: "4k",
|
||||||
|
consistencyScore: 94,
|
||||||
|
createdAt: "2026-05-09T09:00:00.000Z",
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: "asset-desktop",
|
||||||
|
label: "Desktop",
|
||||||
|
ratio: "16:9",
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
imageUrl: image("photo-1500534314209-a25ddb2bd429", 1440, 810)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asset-mobile",
|
||||||
|
label: "Mobile",
|
||||||
|
ratio: "9:16",
|
||||||
|
width: 2160,
|
||||||
|
height: 3840,
|
||||||
|
imageUrl: image("photo-1500534314209-a25ddb2bd429", 900, 1600)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockApiKeys: WebUserApiKey[] = [
|
||||||
|
{
|
||||||
|
id: "key-openai-compatible",
|
||||||
|
provider: "OpenAI Compatible",
|
||||||
|
baseUrl: "https://api.wallmuse-dev.example/v1",
|
||||||
|
model: "gpt-image-1",
|
||||||
|
maskedKey: "sk-...R7m",
|
||||||
|
isDefault: true,
|
||||||
|
status: "connected",
|
||||||
|
updatedAt: "2026-05-09T08:45:00.000Z"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
const path = url.startsWith("http") ? new URL(url).pathname : url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (path === "/api/v1/me") return json(mockUser);
|
||||||
|
if (path === "/api/v1/wallpapers") return json(mockWallpapers);
|
||||||
|
if (path === "/api/v1/generation-groups") return json(mockGenerationHistory);
|
||||||
|
if (path.startsWith("/api/v1/generation-groups/")) {
|
||||||
|
const id = path.split("/").pop();
|
||||||
|
return json(mockGenerationHistory.find((item) => item.id === id) ?? mockGenerationHistory[0]);
|
||||||
|
}
|
||||||
|
if (path === "/api/v1/generations" && method === "POST") {
|
||||||
|
return json(createMockGeneration(init?.body ? JSON.parse(String(init.body)) : undefined));
|
||||||
|
}
|
||||||
|
if (path === "/api/v1/user-api-keys") {
|
||||||
|
if (method === "POST") {
|
||||||
|
const body = init?.body ? (JSON.parse(String(init.body)) as WebSaveApiKeyInput) : undefined;
|
||||||
|
return json({
|
||||||
|
id: "key-new-api",
|
||||||
|
provider: body?.provider ?? "OpenAI Compatible",
|
||||||
|
baseUrl: body?.baseUrl ?? "https://api.example.com/v1",
|
||||||
|
model: body?.model ?? "gpt-image-1",
|
||||||
|
maskedKey: "sk-...9Kp",
|
||||||
|
isDefault: body?.isDefault ?? true,
|
||||||
|
status: "connected",
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json(mockApiKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: `Mock API route not implemented: ${path}` }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockGeneration(input?: WebCreateGenerationInput): WebGenerationGroup {
|
||||||
|
return {
|
||||||
|
id: "gen-live-preview",
|
||||||
|
prompt: input?.prompt ?? "A clean blue wallpaper with glass light",
|
||||||
|
negativePrompt: input?.negativePrompt,
|
||||||
|
status: "succeeded",
|
||||||
|
style: input?.style ?? "Nature",
|
||||||
|
model: input?.model ?? mockModelId,
|
||||||
|
resolution: input?.resolution ?? "2k",
|
||||||
|
consistencyScore: 91,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: "asset-live-desktop",
|
||||||
|
label: "Desktop",
|
||||||
|
ratio: "16:9",
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
imageUrl: image("photo-1506744038136-46273834b3fb", 1440, 810)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asset-live-mobile",
|
||||||
|
label: "Mobile",
|
||||||
|
ratio: "9:16",
|
||||||
|
width: 1440,
|
||||||
|
height: 2560,
|
||||||
|
imageUrl: image("photo-1506744038136-46273834b3fb", 900, 1600)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from "@wallmuse/shared";
|
||||||
10
packages/api-client/tsconfig.json
Executable file
10
packages/api-client/tsconfig.json
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
20
packages/db/package.json
Executable file
20
packages/db/package.json
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"prisma": "prisma --schema prisma/schema.prisma",
|
||||||
|
"prisma:validate": "prisma validate --schema prisma/schema.prisma",
|
||||||
|
"prisma:generate": "prisma generate --schema prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "^6.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
457
packages/db/prisma/schema.prisma
Normal file
457
packages/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
user
|
||||||
|
admin
|
||||||
|
super_admin
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserStatus {
|
||||||
|
active
|
||||||
|
disabled
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProviderStatus {
|
||||||
|
disabled
|
||||||
|
healthy
|
||||||
|
degraded
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ModelStatus {
|
||||||
|
draft
|
||||||
|
enabled
|
||||||
|
disabled
|
||||||
|
deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApiKeyMode {
|
||||||
|
platform
|
||||||
|
user_own
|
||||||
|
hybrid
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthType {
|
||||||
|
bearer
|
||||||
|
api_key
|
||||||
|
custom
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GenerationMode {
|
||||||
|
text_to_image
|
||||||
|
image_to_image
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GenerationQuality {
|
||||||
|
standard
|
||||||
|
hd
|
||||||
|
ultra
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ResolutionTier {
|
||||||
|
one_k @map("1k")
|
||||||
|
two_k @map("2k")
|
||||||
|
four_k @map("4k")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AspectRatio {
|
||||||
|
square @map("1:1")
|
||||||
|
landscape_4_3 @map("4:3")
|
||||||
|
portrait_3_4 @map("3:4")
|
||||||
|
landscape_16_9 @map("16:9")
|
||||||
|
portrait_9_16 @map("9:16")
|
||||||
|
ultrawide_21_9 @map("21:9")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GenerationGroupStatus {
|
||||||
|
queued
|
||||||
|
running
|
||||||
|
partial_succeeded
|
||||||
|
succeeded
|
||||||
|
failed
|
||||||
|
canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GenerationTaskStatus {
|
||||||
|
created
|
||||||
|
queued
|
||||||
|
dispatching
|
||||||
|
running
|
||||||
|
uploading
|
||||||
|
post_processing
|
||||||
|
moderating
|
||||||
|
succeeded
|
||||||
|
failed
|
||||||
|
retrying
|
||||||
|
canceled
|
||||||
|
expired
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssetKind {
|
||||||
|
reference
|
||||||
|
master
|
||||||
|
landscape
|
||||||
|
portrait
|
||||||
|
thumbnail
|
||||||
|
preview
|
||||||
|
download_zip
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssetStatus {
|
||||||
|
temporary
|
||||||
|
active
|
||||||
|
deleted
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WallpaperStatus {
|
||||||
|
draft
|
||||||
|
published
|
||||||
|
hidden
|
||||||
|
rejected
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ModerationStatus {
|
||||||
|
pending
|
||||||
|
passed
|
||||||
|
rejected
|
||||||
|
manual_review
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProviderCallStatus {
|
||||||
|
started
|
||||||
|
success
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DevicePlatform {
|
||||||
|
web
|
||||||
|
ios
|
||||||
|
android
|
||||||
|
desktop
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
email String @unique @db.VarChar(255)
|
||||||
|
name String? @db.VarChar(80)
|
||||||
|
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||||
|
roles UserRole[] @default([user])
|
||||||
|
status UserStatus @default(active)
|
||||||
|
userApiKeys UserApiKey[]
|
||||||
|
generationGroups GenerationGroup[]
|
||||||
|
wallpapers Wallpaper[]
|
||||||
|
preferences UserPreference?
|
||||||
|
devices Device[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserApiKey {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
providerId String @map("provider_id") @db.Uuid
|
||||||
|
name String @db.VarChar(80)
|
||||||
|
maskedKey String @map("masked_key") @db.VarChar(120)
|
||||||
|
encryptedKey String @map("encrypted_key") @db.Text
|
||||||
|
baseUrl String? @map("base_url") @db.VarChar(512)
|
||||||
|
defaultModelId String? @map("default_model_id") @db.Uuid
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
provider Provider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||||
|
defaultModel Model? @relation(fields: [defaultModelId], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
@@index([userId, enabled])
|
||||||
|
@@index([providerId])
|
||||||
|
@@map("user_api_keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Provider {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
slug String @unique @db.VarChar(80)
|
||||||
|
displayName String @map("display_name") @db.VarChar(120)
|
||||||
|
baseUrl String? @map("base_url") @db.VarChar(512)
|
||||||
|
authType AuthType @default(bearer) @map("auth_type")
|
||||||
|
status ProviderStatus @default(healthy)
|
||||||
|
keyMode ApiKeyMode @default(hybrid) @map("key_mode")
|
||||||
|
supportsUserKeys Boolean @default(true) @map("supports_user_keys")
|
||||||
|
supportsPlatformKeys Boolean @default(true) @map("supports_platform_keys")
|
||||||
|
healthCheckPath String? @map("health_check_path") @db.VarChar(255)
|
||||||
|
rateLimitPerMinute Int? @map("rate_limit_per_minute")
|
||||||
|
metadata Json @default("{}")
|
||||||
|
models Model[]
|
||||||
|
userApiKeys UserApiKey[]
|
||||||
|
generationGroups GenerationGroup[]
|
||||||
|
providerCallLogs ProviderCallLog[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("providers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Model {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
providerId String @map("provider_id") @db.Uuid
|
||||||
|
slug String @db.VarChar(160)
|
||||||
|
displayName String @map("display_name") @db.VarChar(160)
|
||||||
|
status ModelStatus @default(enabled)
|
||||||
|
keyMode ApiKeyMode @default(hybrid) @map("key_mode")
|
||||||
|
capability Json
|
||||||
|
pricing Json?
|
||||||
|
defaultParams Json @default("{}") @map("default_params")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||||||
|
sizePresets ModelSizePreset[]
|
||||||
|
userApiKeys UserApiKey[]
|
||||||
|
generationGroups GenerationGroup[]
|
||||||
|
generationTasks GenerationTask[]
|
||||||
|
generatedAssets GeneratedAsset[]
|
||||||
|
providerCallLogs ProviderCallLog[]
|
||||||
|
wallpapers Wallpaper[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([providerId, slug])
|
||||||
|
@@index([status, sortOrder])
|
||||||
|
@@map("models")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ModelSizePreset {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
modelId String @map("model_id") @db.Uuid
|
||||||
|
aspectRatio AspectRatio @map("aspect_ratio")
|
||||||
|
resolution ResolutionTier
|
||||||
|
width Int
|
||||||
|
height Int
|
||||||
|
providerSizeValue String? @map("provider_size_value") @db.VarChar(80)
|
||||||
|
native Boolean @default(true)
|
||||||
|
requiresUpscale Boolean @default(false) @map("requires_upscale")
|
||||||
|
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([modelId, aspectRatio, resolution])
|
||||||
|
@@map("model_size_presets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GenerationGroup {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
providerId String @map("provider_id") @db.Uuid
|
||||||
|
modelId String @map("model_id") @db.Uuid
|
||||||
|
userApiKeyId String? @map("user_api_key_id") @db.Uuid
|
||||||
|
status GenerationGroupStatus @default(queued)
|
||||||
|
mode GenerationMode
|
||||||
|
prompt String @db.Text
|
||||||
|
negativePrompt String? @map("negative_prompt") @db.Text
|
||||||
|
resolution ResolutionTier
|
||||||
|
quality GenerationQuality @default(standard)
|
||||||
|
batchSize Int @default(1) @map("batch_size")
|
||||||
|
seed Int?
|
||||||
|
privacy String @default("private") @db.VarChar(24)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
provider Provider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||||
|
model Model @relation(fields: [modelId], references: [id], onDelete: Restrict)
|
||||||
|
tasks GenerationTask[]
|
||||||
|
assets GeneratedAsset[]
|
||||||
|
wallpapers Wallpaper[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@map("generation_groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GenerationTask {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
groupId String @map("group_id") @db.Uuid
|
||||||
|
modelId String @map("model_id") @db.Uuid
|
||||||
|
status GenerationTaskStatus @default(created)
|
||||||
|
mode GenerationMode
|
||||||
|
aspectRatio AspectRatio @map("aspect_ratio")
|
||||||
|
resolution ResolutionTier
|
||||||
|
quality GenerationQuality @default(standard)
|
||||||
|
priority Int @default(0)
|
||||||
|
attempt Int @default(0)
|
||||||
|
maxAttempts Int @default(3) @map("max_attempts")
|
||||||
|
progress Int @default(0)
|
||||||
|
errorCode String? @map("error_code") @db.VarChar(120)
|
||||||
|
errorMessage String? @map("error_message") @db.Text
|
||||||
|
lockedAt DateTime? @map("locked_at")
|
||||||
|
startedAt DateTime? @map("started_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
group GenerationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
model Model @relation(fields: [modelId], references: [id], onDelete: Restrict)
|
||||||
|
assets GeneratedAsset[]
|
||||||
|
providerCallLogs ProviderCallLog[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([status, priority, createdAt], map: "idx_generation_tasks_status_priority")
|
||||||
|
@@index([groupId])
|
||||||
|
@@map("generation_tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GeneratedAsset {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
groupId String @map("group_id") @db.Uuid
|
||||||
|
taskId String? @map("task_id") @db.Uuid
|
||||||
|
providerId String? @map("provider_id") @db.Uuid
|
||||||
|
modelId String? @map("model_id") @db.Uuid
|
||||||
|
kind AssetKind
|
||||||
|
status AssetStatus @default(temporary)
|
||||||
|
width Int?
|
||||||
|
height Int?
|
||||||
|
mimeType String? @map("mime_type") @db.VarChar(120)
|
||||||
|
bucket String? @db.VarChar(120)
|
||||||
|
objectKey String? @map("object_key") @db.VarChar(512)
|
||||||
|
storageUrl String? @map("storage_url") @db.Text
|
||||||
|
publicUrl String? @map("public_url") @db.Text
|
||||||
|
blurHash String? @map("blur_hash") @db.VarChar(255)
|
||||||
|
byteSize BigInt? @map("byte_size")
|
||||||
|
sha256 String? @db.VarChar(64)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
group GenerationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
task GenerationTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||||
|
model Model? @relation(fields: [modelId], references: [id], onDelete: SetNull)
|
||||||
|
wallpapers Wallpaper[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([groupId])
|
||||||
|
@@index([taskId])
|
||||||
|
@@map("generated_assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Wallpaper {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
groupId String? @map("group_id") @db.Uuid
|
||||||
|
assetId String? @map("asset_id") @db.Uuid
|
||||||
|
modelId String? @map("model_id") @db.Uuid
|
||||||
|
title String @db.VarChar(160)
|
||||||
|
description String? @db.Text
|
||||||
|
prompt String? @db.Text
|
||||||
|
status WallpaperStatus @default(draft)
|
||||||
|
isFeatured Boolean @default(false) @map("is_featured")
|
||||||
|
tags String[] @default([])
|
||||||
|
metadata Json @default("{}")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
group GenerationGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||||
|
asset GeneratedAsset? @relation(fields: [assetId], references: [id], onDelete: SetNull)
|
||||||
|
model Model? @relation(fields: [modelId], references: [id], onDelete: SetNull)
|
||||||
|
moderationRecords ModerationRecord[]
|
||||||
|
publishedAt DateTime? @map("published_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([status, isFeatured, publishedAt], map: "idx_wallpapers_status_featured_published")
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@map("wallpapers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ModerationRecord {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
wallpaperId String? @map("wallpaper_id") @db.Uuid
|
||||||
|
assetId String? @map("asset_id") @db.Uuid
|
||||||
|
status ModerationStatus @default(pending)
|
||||||
|
reason String? @db.Text
|
||||||
|
metadata Json @default("{}")
|
||||||
|
reviewerId String? @map("reviewer_id") @db.Uuid
|
||||||
|
wallpaper Wallpaper? @relation(fields: [wallpaperId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@map("moderation_records")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProviderCallLog {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
taskId String? @map("task_id") @db.Uuid
|
||||||
|
providerId String @map("provider_id") @db.Uuid
|
||||||
|
modelId String? @map("model_id") @db.Uuid
|
||||||
|
userApiKeyId String? @map("user_api_key_id") @db.Uuid
|
||||||
|
status ProviderCallStatus
|
||||||
|
latencyMs Int? @map("latency_ms")
|
||||||
|
errorCode String? @map("error_code") @db.VarChar(120)
|
||||||
|
errorMessage String? @map("error_message") @db.Text
|
||||||
|
requestId String? @map("request_id") @db.VarChar(160)
|
||||||
|
providerCost Decimal? @map("provider_cost") @db.Decimal(12, 6)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
task GenerationTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||||
|
provider Provider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||||
|
model Model? @relation(fields: [modelId], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([taskId, createdAt], map: "idx_provider_logs_task")
|
||||||
|
@@index([providerId, createdAt])
|
||||||
|
@@map("provider_call_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserPreference {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @unique @map("user_id") @db.Uuid
|
||||||
|
defaultModelId String? @map("default_model_id") @db.Uuid
|
||||||
|
defaultResolution ResolutionTier? @map("default_resolution")
|
||||||
|
defaultAspectRatios String[] @default([]) @map("default_aspect_ratios")
|
||||||
|
defaultPrivacy String @default("private") @map("default_privacy") @db.VarChar(24)
|
||||||
|
metadata Json @default("{}")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("user_preferences")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Device {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String? @map("user_id") @db.Uuid
|
||||||
|
platform DevicePlatform
|
||||||
|
appVersion String? @map("app_version") @db.VarChar(40)
|
||||||
|
pushToken String? @map("push_token") @db.Text
|
||||||
|
metadata Json @default("{}")
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId, platform])
|
||||||
|
@@map("devices")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String? @map("user_id") @db.Uuid
|
||||||
|
title String @db.VarChar(160)
|
||||||
|
body String @db.Text
|
||||||
|
readAt DateTime? @map("read_at")
|
||||||
|
metadata Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([userId, readAt, createdAt])
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AppConfig {
|
||||||
|
key String @id @db.VarChar(120)
|
||||||
|
value Json
|
||||||
|
description String? @db.Text
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("app_config")
|
||||||
|
}
|
||||||
1
packages/db/src/index.ts
Executable file
1
packages/db/src/index.ts
Executable file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./json-store";
|
||||||
523
packages/db/src/json-store.ts
Executable file
523
packages/db/src/json-store.ts
Executable file
@@ -0,0 +1,523 @@
|
|||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import type {
|
||||||
|
AppConfigResponse,
|
||||||
|
AssetKind,
|
||||||
|
GeneratedAsset,
|
||||||
|
GenerationGroup,
|
||||||
|
GenerationGroupStatus,
|
||||||
|
GenerationTaskStatus,
|
||||||
|
ModelSummary,
|
||||||
|
ProviderSummary
|
||||||
|
} from "@wallmuse/shared";
|
||||||
|
|
||||||
|
type JsonUserRole = "user" | "admin" | "super_admin";
|
||||||
|
|
||||||
|
export interface UserRecord {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
roles: JsonUserRole[];
|
||||||
|
passwordHash: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserApiKeyRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
providerId: string;
|
||||||
|
name: string;
|
||||||
|
maskedKey: string;
|
||||||
|
encryptedKey: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
defaultModelId?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderRecord extends ProviderSummary {
|
||||||
|
baseUrl?: string;
|
||||||
|
authType: "bearer" | "api_key" | "custom";
|
||||||
|
supportsUserKeys: boolean;
|
||||||
|
supportsPlatformKeys: boolean;
|
||||||
|
healthCheckPath?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderCallLogRecord {
|
||||||
|
id: string;
|
||||||
|
taskId?: string;
|
||||||
|
groupId?: string;
|
||||||
|
providerId: string;
|
||||||
|
modelId?: string;
|
||||||
|
assetKind?: AssetKind;
|
||||||
|
status: "started" | "succeeded" | "success" | "failed";
|
||||||
|
attempt?: number;
|
||||||
|
latencyMs?: number;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
usage?: unknown;
|
||||||
|
rawMetadata?: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationWorkerJobData {
|
||||||
|
groupId: string;
|
||||||
|
taskId?: string;
|
||||||
|
userId: string;
|
||||||
|
mode: "text_to_image" | "image_to_image";
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
resolution: "1k" | "2k" | "4k";
|
||||||
|
providerId: string;
|
||||||
|
providerSlug: string;
|
||||||
|
modelId: string;
|
||||||
|
modelSlug: string;
|
||||||
|
referenceAssetUrl?: string;
|
||||||
|
retryAssetKind?: Extract<AssetKind, "master" | "landscape" | "portrait">;
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredProviderAssetInput {
|
||||||
|
groupId: string;
|
||||||
|
taskId?: string;
|
||||||
|
userId: string;
|
||||||
|
assetKind: Extract<AssetKind, "master" | "landscape" | "portrait">;
|
||||||
|
status: "active" | "failed";
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
publicUrl?: string;
|
||||||
|
storageUrl?: string;
|
||||||
|
objectKey?: string;
|
||||||
|
bucket?: string;
|
||||||
|
byteSize?: number;
|
||||||
|
sha256?: string;
|
||||||
|
providerId: string;
|
||||||
|
modelId: string;
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredGenerationGroup extends GenerationGroup {
|
||||||
|
userId: string;
|
||||||
|
providerId: string;
|
||||||
|
privacy: "private" | "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataStoreShape {
|
||||||
|
appConfigUpdatedAt: string;
|
||||||
|
users: UserRecord[];
|
||||||
|
providers: ProviderRecord[];
|
||||||
|
models: ModelSummary[];
|
||||||
|
userApiKeys: UserApiKeyRecord[];
|
||||||
|
generationGroups: StoredGenerationGroup[];
|
||||||
|
providerCallLogs: ProviderCallLogRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = (): string => new Date().toISOString();
|
||||||
|
const mockProviderId = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const mockModelId = "00000000-0000-4000-8000-000000000101";
|
||||||
|
|
||||||
|
const createDefaultProvider = (now: string): ProviderRecord => ({
|
||||||
|
id: mockProviderId,
|
||||||
|
slug: "mock",
|
||||||
|
displayName: "Mock Provider",
|
||||||
|
status: "healthy",
|
||||||
|
keyMode: "hybrid",
|
||||||
|
modelCount: 1,
|
||||||
|
authType: "bearer",
|
||||||
|
supportsUserKeys: true,
|
||||||
|
supportsPlatformKeys: true,
|
||||||
|
healthCheckPath: "/v1/models",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultModel = (): ModelSummary => ({
|
||||||
|
id: mockModelId,
|
||||||
|
providerId: mockProviderId,
|
||||||
|
slug: "mock-wallpaper-v1",
|
||||||
|
displayName: "Mock Wallpaper v1",
|
||||||
|
status: "enabled",
|
||||||
|
keyMode: "hybrid",
|
||||||
|
sortOrder: 0,
|
||||||
|
capability: {
|
||||||
|
supportsTextToImage: true,
|
||||||
|
supportsImageToImage: true,
|
||||||
|
supportsEdit: false,
|
||||||
|
supportsNegativePrompt: true,
|
||||||
|
supportsSeed: true,
|
||||||
|
supportsBatch: false,
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsBase64Result: false,
|
||||||
|
supportsUrlResult: true,
|
||||||
|
supportsNative4k: false,
|
||||||
|
maxBatchSize: 1,
|
||||||
|
maxInputImages: 3,
|
||||||
|
maxPromptLength: 4000,
|
||||||
|
maxNegativePromptLength: 2000,
|
||||||
|
maxPixels: 3686400,
|
||||||
|
supportedAspectRatios: ["16:9", "9:16", "1:1"],
|
||||||
|
supportedResolutions: ["1k", "2k", "4k"],
|
||||||
|
sizePresets: [
|
||||||
|
{
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
resolution: "2k",
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
providerSizeValue: "2560x1440",
|
||||||
|
native: true,
|
||||||
|
requiresUpscale: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
resolution: "2k",
|
||||||
|
width: 1440,
|
||||||
|
height: 2560,
|
||||||
|
providerSizeValue: "1440x2560",
|
||||||
|
native: true,
|
||||||
|
requiresUpscale: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultParams: {}
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
currency: "USD",
|
||||||
|
unit: "image",
|
||||||
|
amount: 0,
|
||||||
|
estimatedCredits: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyData = (): DataStoreShape => ({
|
||||||
|
appConfigUpdatedAt: nowIso(),
|
||||||
|
users: [],
|
||||||
|
providers: [],
|
||||||
|
models: [],
|
||||||
|
userApiKeys: [],
|
||||||
|
generationGroups: [],
|
||||||
|
providerCallLogs: []
|
||||||
|
});
|
||||||
|
|
||||||
|
export class JsonWallMuseDb {
|
||||||
|
private writeQueue = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {}
|
||||||
|
|
||||||
|
static fromEnv(): JsonWallMuseDb {
|
||||||
|
const dataDir = process.env.WALLMUSE_DATA_DIR ?? join(process.cwd(), ".data");
|
||||||
|
return new JsonWallMuseDb(join(dataDir, "wallmuse-api.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await mkdir(dirname(this.filePath), { recursive: true });
|
||||||
|
const data = await this.read();
|
||||||
|
if (!data.providers.some((provider) => provider.slug === "mock")) {
|
||||||
|
const now = nowIso();
|
||||||
|
data.providers.push(createDefaultProvider(now));
|
||||||
|
data.models.push(createDefaultModel());
|
||||||
|
await this.write(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(): Promise<DataStoreShape> {
|
||||||
|
try {
|
||||||
|
const text = await readFile(this.filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(text) as Partial<DataStoreShape>;
|
||||||
|
return { ...emptyData(), ...parsed };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return emptyData();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async mutate<T>(fn: (data: DataStoreShape) => T | Promise<T>): Promise<T> {
|
||||||
|
const run = async () => {
|
||||||
|
const data = await this.read();
|
||||||
|
const result = await fn(data);
|
||||||
|
await this.write(data);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const next = this.writeQueue.then(run, run);
|
||||||
|
this.writeQueue = next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppConfig(data: DataStoreShape): AppConfigResponse {
|
||||||
|
return {
|
||||||
|
site: { name: "WallMuse" },
|
||||||
|
generation: {
|
||||||
|
defaultModelId: data.models[0]?.id,
|
||||||
|
defaultAspectRatios: ["16:9", "9:16"],
|
||||||
|
defaultResolution: "2k",
|
||||||
|
maxBatchSize: 4,
|
||||||
|
allowedResolutions: ["1k", "2k", "4k"]
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
authEnabled: true,
|
||||||
|
galleryEnabled: true,
|
||||||
|
userApiKeysEnabled: true,
|
||||||
|
generationEnabled: true,
|
||||||
|
darkModeEnabled: true
|
||||||
|
},
|
||||||
|
providers: data.providers,
|
||||||
|
models: data.models,
|
||||||
|
updatedAt: data.appConfigUpdatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedQueuedGeneration(job: GenerationWorkerJobData): Promise<void> {
|
||||||
|
await this.init();
|
||||||
|
const now = nowIso();
|
||||||
|
await this.mutate((data) => {
|
||||||
|
let group = data.generationGroups.find((item) => item.id === job.groupId);
|
||||||
|
if (!group) {
|
||||||
|
const taskId = job.taskId ?? crypto.randomUUID();
|
||||||
|
group = {
|
||||||
|
id: job.groupId,
|
||||||
|
userId: job.userId,
|
||||||
|
providerId: job.providerId,
|
||||||
|
privacy: "private",
|
||||||
|
status: "queued",
|
||||||
|
modelId: job.modelId,
|
||||||
|
prompt: job.prompt,
|
||||||
|
negativePrompt: job.negativePrompt,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: taskId,
|
||||||
|
groupId: job.groupId,
|
||||||
|
status: "queued",
|
||||||
|
mode: job.mode,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
resolution: job.resolution,
|
||||||
|
quality: "standard",
|
||||||
|
attempt: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
progress: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
groupId: job.groupId,
|
||||||
|
status: "queued",
|
||||||
|
mode: job.mode,
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
resolution: job.resolution,
|
||||||
|
quality: "standard",
|
||||||
|
attempt: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
progress: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
],
|
||||||
|
assets: makeGenerationAssets({ "16:9": taskId, "9:16": undefined }, now),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
data.generationGroups.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = findWorkerTask(group, job.taskId);
|
||||||
|
if (task) {
|
||||||
|
task.status = "queued";
|
||||||
|
task.progress = 0;
|
||||||
|
task.errorCode = undefined;
|
||||||
|
task.errorMessage = undefined;
|
||||||
|
task.updatedAt = now;
|
||||||
|
}
|
||||||
|
group.status = "queued";
|
||||||
|
group.updatedAt = now;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markGenerationStatus(
|
||||||
|
groupId: string,
|
||||||
|
taskId: string | undefined,
|
||||||
|
groupStatus: GenerationGroupStatus,
|
||||||
|
taskStatus: GenerationTaskStatus,
|
||||||
|
progress: number
|
||||||
|
): Promise<void> {
|
||||||
|
const now = nowIso();
|
||||||
|
await this.mutate((data) => {
|
||||||
|
const group = requireWorkerGenerationGroup(data, groupId);
|
||||||
|
const task = findWorkerTask(group, taskId);
|
||||||
|
group.status = groupStatus;
|
||||||
|
group.updatedAt = now;
|
||||||
|
if (task) {
|
||||||
|
task.status = taskStatus;
|
||||||
|
task.progress = progress;
|
||||||
|
task.updatedAt = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementGenerationAttempt(groupId: string, taskId?: string): Promise<number> {
|
||||||
|
let attempt = 1;
|
||||||
|
await this.mutate((data) => {
|
||||||
|
const group = requireWorkerGenerationGroup(data, groupId);
|
||||||
|
const task = findWorkerTask(group, taskId);
|
||||||
|
if (task) {
|
||||||
|
task.attempt += 1;
|
||||||
|
task.updatedAt = nowIso();
|
||||||
|
attempt = task.attempt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markGenerationFailure(
|
||||||
|
groupId: string,
|
||||||
|
taskId: string | undefined,
|
||||||
|
groupStatus: "failed" | "partial_succeeded",
|
||||||
|
errorCode: string,
|
||||||
|
errorMessage: string
|
||||||
|
): Promise<void> {
|
||||||
|
const now = nowIso();
|
||||||
|
await this.mutate((data) => {
|
||||||
|
const group = requireWorkerGenerationGroup(data, groupId);
|
||||||
|
const task = findWorkerTask(group, taskId);
|
||||||
|
group.status = groupStatus;
|
||||||
|
group.updatedAt = now;
|
||||||
|
if (task) {
|
||||||
|
task.status = "failed";
|
||||||
|
task.errorCode = errorCode;
|
||||||
|
task.errorMessage = errorMessage;
|
||||||
|
task.updatedAt = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProviderCallLog(input: Omit<ProviderCallLogRecord, "createdAt" | "updatedAt" | "status">): Promise<ProviderCallLogRecord> {
|
||||||
|
const now = nowIso();
|
||||||
|
const log: ProviderCallLogRecord = { ...input, status: "started", createdAt: now, updatedAt: now };
|
||||||
|
await this.mutate((data) => {
|
||||||
|
data.providerCallLogs.push(log);
|
||||||
|
});
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeProviderCallLog(
|
||||||
|
id: string,
|
||||||
|
patch: Pick<ProviderCallLogRecord, "status" | "latencyMs" | "usage" | "rawMetadata" | "errorCode" | "errorMessage">
|
||||||
|
): Promise<void> {
|
||||||
|
await this.mutate((data) => {
|
||||||
|
const log = data.providerCallLogs.find((item) => item.id === id);
|
||||||
|
if (!log) {
|
||||||
|
throw new Error(`Provider call log not found: ${id}`);
|
||||||
|
}
|
||||||
|
Object.assign(log, patch, { updatedAt: nowIso() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertGeneratedAsset(input: StoredProviderAssetInput): Promise<GeneratedAsset> {
|
||||||
|
const now = nowIso();
|
||||||
|
let asset: GeneratedAsset | undefined;
|
||||||
|
await this.mutate((data) => {
|
||||||
|
const group = requireWorkerGenerationGroup(data, input.groupId);
|
||||||
|
const existing = group.assets.find((item) => item.kind === input.assetKind);
|
||||||
|
const metadata = {
|
||||||
|
storageUrl: input.storageUrl,
|
||||||
|
objectKey: input.objectKey,
|
||||||
|
bucket: input.bucket,
|
||||||
|
byteSize: input.byteSize,
|
||||||
|
sha256: input.sha256,
|
||||||
|
providerId: input.providerId,
|
||||||
|
modelId: input.modelId,
|
||||||
|
seed: input.seed
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (input.taskId !== undefined) existing.taskId = input.taskId;
|
||||||
|
existing.status = input.status;
|
||||||
|
existing.width = input.width;
|
||||||
|
existing.height = input.height;
|
||||||
|
existing.mimeType = input.mimeType;
|
||||||
|
existing.publicUrl = input.publicUrl;
|
||||||
|
Object.assign(existing as GeneratedAsset & { metadata?: unknown }, { metadata });
|
||||||
|
asset = existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
asset = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
||||||
|
kind: input.assetKind,
|
||||||
|
status: input.status,
|
||||||
|
...(input.width === undefined ? {} : { width: input.width }),
|
||||||
|
...(input.height === undefined ? {} : { height: input.height }),
|
||||||
|
...(input.mimeType === undefined ? {} : { mimeType: input.mimeType }),
|
||||||
|
...(input.publicUrl === undefined ? {} : { publicUrl: input.publicUrl }),
|
||||||
|
createdAt: now
|
||||||
|
};
|
||||||
|
Object.assign(asset as GeneratedAsset & { metadata?: unknown }, { metadata });
|
||||||
|
group.assets.push(asset);
|
||||||
|
group.updatedAt = now;
|
||||||
|
});
|
||||||
|
return asset!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async write(data: DataStoreShape): Promise<void> {
|
||||||
|
await mkdir(dirname(this.filePath), { recursive: true });
|
||||||
|
const tmpPath = `${this.filePath}.${process.pid}.tmp`;
|
||||||
|
await writeFile(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||||
|
await rename(tmpPath, this.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toPublicUser = (user: UserRecord) => ({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
roles: user.roles,
|
||||||
|
createdAt: user.createdAt
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hasAdminRole = (roles: JsonUserRole[]): boolean =>
|
||||||
|
roles.includes("admin") || roles.includes("super_admin");
|
||||||
|
|
||||||
|
export const makeGenerationAssets = (
|
||||||
|
taskIds: Record<"16:9" | "9:16", string | undefined>,
|
||||||
|
now: string
|
||||||
|
): GeneratedAsset[] => {
|
||||||
|
const assets: GeneratedAsset[] = [{ id: crypto.randomUUID(), kind: "master", status: "temporary", createdAt: now }];
|
||||||
|
if (taskIds["16:9"]) {
|
||||||
|
assets.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
taskId: taskIds["16:9"],
|
||||||
|
kind: "landscape",
|
||||||
|
status: "temporary",
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
createdAt: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (taskIds["9:16"]) {
|
||||||
|
assets.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
taskId: taskIds["9:16"],
|
||||||
|
kind: "portrait",
|
||||||
|
status: "temporary",
|
||||||
|
width: 1440,
|
||||||
|
height: 2560,
|
||||||
|
createdAt: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireWorkerGenerationGroup = (data: DataStoreShape, groupId: string): StoredGenerationGroup => {
|
||||||
|
const group = data.generationGroups.find((item) => item.id === groupId);
|
||||||
|
if (!group) throw new Error(`Generation group not found: ${groupId}`);
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
const findWorkerTask = (group: StoredGenerationGroup, taskId?: string) => group.tasks.find((task) => task.id === taskId) ?? group.tasks[0];
|
||||||
13
packages/db/tsconfig.json
Executable file
13
packages/db/tsconfig.json
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
packages/image-pipeline/package.json
Normal file
11
packages/image-pipeline/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/image-pipeline",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" } },
|
||||||
|
"scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "build": "tsc -p tsconfig.json" },
|
||||||
|
"dependencies": { "@wallmuse/shared": "workspace:*" }
|
||||||
|
}
|
||||||
7
packages/image-pipeline/src/index.ts
Normal file
7
packages/image-pipeline/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AssetKind } from "@wallmuse/shared";
|
||||||
|
export interface StoredProviderImage { bucket: string; objectKey: string; storageUrl: string; publicUrl: string; sha256: string; byteSize: number; mimeType: string; width?: number; height?: number; }
|
||||||
|
export interface StoreProviderAssetInput { userId: string; groupId: string; assetKind: Extract<AssetKind, "master" | "landscape" | "portrait">; source: { kind: "url" | "base64"; value: string; mimeType?: string; width?: number; height?: number } }
|
||||||
|
export class LocalProviderAssetStore { constructor(private readonly rootDir = process.env.WALLMUSE_STORAGE_DIR ?? ".wallmuse-data/storage") {} async storeProviderAsset(input: StoreProviderAssetInput): Promise<StoredProviderImage> { const bytes = await this.readSource(input.source); const mimeType = input.source.mimeType ?? "image/png"; const extension = mimeType.includes("jpeg") ? "jpg" : mimeType.includes("webp") ? "webp" : "png"; const objectKey = `users/${input.userId}/groups/${input.groupId}/${input.assetKind}/original.${extension}`; const targetPath = path.join(this.rootDir, "wallpaper-originals", objectKey); await mkdir(path.dirname(targetPath), { recursive: true }); await writeFile(targetPath, bytes); const stored: StoredProviderImage = { bucket: "wallpaper-originals", objectKey, storageUrl: `local://${path.resolve(targetPath)}`, publicUrl: `/storage/wallpaper-originals/${objectKey}`, sha256: createHash("sha256").update(bytes).digest("hex"), byteSize: bytes.byteLength, mimeType }; if (input.source.width !== undefined) stored.width = input.source.width; if (input.source.height !== undefined) stored.height = input.source.height; return stored; } private async readSource(source: StoreProviderAssetInput["source"]): Promise<Buffer> { if (source.kind === "base64") return Buffer.from(source.value, "base64"); if (source.value.startsWith("mock://")) return Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64"); const response = await fetch(source.value); if (!response.ok) throw new Error(`Failed to download provider URL: ${response.status} ${response.statusText}`); return Buffer.from(await response.arrayBuffer()); } }
|
||||||
1
packages/image-pipeline/tsconfig.json
Normal file
1
packages/image-pipeline/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "declaration": true, "types": ["node"] }, "include": ["src/**/*.ts"] }
|
||||||
25
packages/provider-adapters/package.json
Normal file
25
packages/provider-adapters/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/provider-adapters",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wallmuse/shared": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createDefaultProviderRegistry,
|
||||||
|
MockImageProviderAdapter,
|
||||||
|
normalizeImageAssets,
|
||||||
|
normalizeProviderError,
|
||||||
|
OpenAICompatibleImageProviderAdapter,
|
||||||
|
SiliconFlowImageProviderAdapter
|
||||||
|
} from "../index";
|
||||||
|
|
||||||
|
describe("provider adapter registry", () => {
|
||||||
|
it("creates implemented and reserved adapters", () => {
|
||||||
|
const registry = createDefaultProviderRegistry();
|
||||||
|
|
||||||
|
expect(registry.mock.provider).toBe("mock");
|
||||||
|
expect(registry["openai-compatible"].provider).toBe("openai-compatible");
|
||||||
|
expect(registry.siliconflow.provider).toBe("siliconflow");
|
||||||
|
expect(registry.dashscope.provider).toBe("dashscope");
|
||||||
|
expect(registry.volcengine.provider).toBe("volcengine");
|
||||||
|
expect(registry.zhipu.provider).toBe("zhipu");
|
||||||
|
expect(registry["custom-template"].provider).toBe("custom-template");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mock provider", () => {
|
||||||
|
it("returns deterministic URL assets with mapped dimensions", async () => {
|
||||||
|
const adapter = new MockImageProviderAdapter();
|
||||||
|
const result = await adapter.generateTextToImage(
|
||||||
|
{
|
||||||
|
model: "mock-wallpaper",
|
||||||
|
prompt: "misty mountain wallpaper",
|
||||||
|
size: { aspectRatio: "16:9", resolution: "2k" },
|
||||||
|
seed: 42
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.assets[0]).toMatchObject({
|
||||||
|
kind: "url",
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
seed: 42
|
||||||
|
});
|
||||||
|
expect(result.usage?.imageCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can return base64 assets for local tests", async () => {
|
||||||
|
const adapter = new MockImageProviderAdapter();
|
||||||
|
const result = await adapter.generateTextToImage(
|
||||||
|
{
|
||||||
|
model: "mock-wallpaper",
|
||||||
|
prompt: "phone wallpaper",
|
||||||
|
responseFormat: "base64",
|
||||||
|
size: { aspectRatio: "9:16", resolution: "1k" }
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.assets[0]?.kind).toBe("base64");
|
||||||
|
expect(result.assets[0]?.mimeType).toBe("image/png");
|
||||||
|
expect(result.assets[0]?.width).toBe(720);
|
||||||
|
expect(result.assets[0]?.height).toBe(1280);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenAI compatible provider", () => {
|
||||||
|
it("posts to configurable image endpoint and normalizes base64 response", async () => {
|
||||||
|
const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
|
||||||
|
expect(_url).toBe("https://new-api.example.com/v1/images/generations");
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
model: "gpt-image-1",
|
||||||
|
prompt: "desktop wallpaper",
|
||||||
|
n: 1,
|
||||||
|
size: "1536x1024",
|
||||||
|
response_format: "b64_json"
|
||||||
|
});
|
||||||
|
return jsonResponse({
|
||||||
|
data: [{ b64_json: "data:image/png;base64,abc123" }],
|
||||||
|
usage: { input_tokens: 12, output_tokens: 0 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const adapter = new OpenAICompatibleImageProviderAdapter();
|
||||||
|
|
||||||
|
const result = await adapter.generateTextToImage(
|
||||||
|
{
|
||||||
|
baseUrl: "https://new-api.example.com",
|
||||||
|
model: "gpt-image-1",
|
||||||
|
prompt: "desktop wallpaper",
|
||||||
|
responseFormat: "base64",
|
||||||
|
size: { aspectRatio: "16:9", resolution: "4k" }
|
||||||
|
},
|
||||||
|
{ auth: { apiKey: "test-key" }, fetch }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.assets[0]).toMatchObject({
|
||||||
|
kind: "base64",
|
||||||
|
value: "abc123",
|
||||||
|
mimeType: "image/png",
|
||||||
|
width: 1536,
|
||||||
|
height: 1024
|
||||||
|
});
|
||||||
|
expect(result.usage?.inputTokens).toBe(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SiliconFlow provider", () => {
|
||||||
|
it("uses image_size and normalizes URL response", async () => {
|
||||||
|
const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
|
||||||
|
expect(_url).toBe("https://api.siliconflow.cn/v1/images/generations");
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
model: "black-forest-labs/FLUX.2-pro",
|
||||||
|
prompt: "portrait wallpaper",
|
||||||
|
image_size: "720x1280",
|
||||||
|
batch_size: 1,
|
||||||
|
seed: 7
|
||||||
|
});
|
||||||
|
return jsonResponse({ data: [{ url: "https://cdn.example.com/result.png" }] });
|
||||||
|
});
|
||||||
|
const adapter = new SiliconFlowImageProviderAdapter();
|
||||||
|
|
||||||
|
const result = await adapter.generateTextToImage(
|
||||||
|
{
|
||||||
|
model: "black-forest-labs/FLUX.2-pro",
|
||||||
|
prompt: "portrait wallpaper",
|
||||||
|
size: { aspectRatio: "9:16", resolution: "1k" },
|
||||||
|
seed: 7
|
||||||
|
},
|
||||||
|
{ auth: { apiKey: "sf-key" }, fetch }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.assets[0]).toMatchObject({
|
||||||
|
kind: "url",
|
||||||
|
value: "https://cdn.example.com/result.png",
|
||||||
|
width: 720,
|
||||||
|
height: 1280
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalization helpers", () => {
|
||||||
|
it("normalizes common provider errors", () => {
|
||||||
|
expect(normalizeProviderError("siliconflow", { status: 429, message: "rate limit exceeded" })).toMatchObject({
|
||||||
|
category: "rate_limit",
|
||||||
|
retryable: true,
|
||||||
|
statusCode: 429
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeProviderError("openai-compatible", { status: 401, message: "invalid api key" })).toMatchObject({
|
||||||
|
category: "authentication",
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeProviderError("openai-compatible", { status: 504, message: "gateway timeout" })).toMatchObject({
|
||||||
|
category: "timeout",
|
||||||
|
retryable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes URL and base64 image payloads", () => {
|
||||||
|
expect(normalizeImageAssets({ data: [{ url: "https://example.com/a.png" }] })[0]).toMatchObject({
|
||||||
|
kind: "url",
|
||||||
|
value: "https://example.com/a.png"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeImageAssets({ images: [{ image_base64: "data:image/jpeg;base64,zzz" }] })[0]).toMatchObject({
|
||||||
|
kind: "base64",
|
||||||
|
value: "zzz",
|
||||||
|
mimeType: "image/jpeg"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
165
packages/provider-adapters/src/adapters/base-http-adapter.ts
Normal file
165
packages/provider-adapters/src/adapters/base-http-adapter.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { normalizeImageAssets, normalizeUsage } from "../asset-normalizer";
|
||||||
|
import { normalizeProviderError, providerHttpError } from "../errors";
|
||||||
|
import { resolveModelCapability, resolveSize } from "../capabilities/presets";
|
||||||
|
import type {
|
||||||
|
ImageGenerationResult,
|
||||||
|
ImageProviderAdapter,
|
||||||
|
ProviderCallContext,
|
||||||
|
ProviderCode,
|
||||||
|
ProviderEndpointConfig,
|
||||||
|
ProviderError,
|
||||||
|
TestConnectionInput,
|
||||||
|
TestConnectionResult,
|
||||||
|
TextToImageInput
|
||||||
|
} from "../types";
|
||||||
|
import { joinUrl } from "../url";
|
||||||
|
|
||||||
|
export abstract class BaseHttpImageAdapter implements ImageProviderAdapter {
|
||||||
|
abstract readonly provider: ProviderCode;
|
||||||
|
abstract readonly capabilities: ImageProviderAdapter["capabilities"];
|
||||||
|
|
||||||
|
protected abstract readonly defaultBaseUrl: string;
|
||||||
|
protected abstract readonly defaultEndpointPath: string;
|
||||||
|
|
||||||
|
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const fetchImpl = input.fetch ?? globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fetchImpl) {
|
||||||
|
throw new Error("No fetch implementation is available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchImpl(this.resolveUrl(input), {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.buildHeaders(input.auth ?? {}, input.defaultHeaders),
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: input.model ?? Object.keys(this.capabilities)[0] ?? "default",
|
||||||
|
prompt: "WallMuse provider connection test",
|
||||||
|
n: 1,
|
||||||
|
...this.testPayloadExtras()
|
||||||
|
}),
|
||||||
|
...withSignal(abortSignal(input.timeoutMs))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
provider: this.provider,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
...withModel(input.model),
|
||||||
|
error: providerHttpError(this.provider, response.status, await safeJson(response))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
provider: this.provider,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
...withModel(input.model),
|
||||||
|
rawMetadata: await safeJson(response)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
provider: this.provider,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
...withModel(input.model),
|
||||||
|
error: this.normalizeError(error)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTextToImage(input: TextToImageInput, context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||||
|
const fetchImpl = context.fetch ?? globalThis.fetch;
|
||||||
|
if (!fetchImpl) {
|
||||||
|
throw new Error("No fetch implementation is available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability = resolveModelCapability(this.capabilities, input.model, input.capability);
|
||||||
|
const size = resolveSize(capability, input.size);
|
||||||
|
const response = await fetchImpl(this.resolveUrl(input), {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.buildHeaders(context.auth ?? {}, input.defaultHeaders),
|
||||||
|
body: JSON.stringify(this.toRequestBody(input, size.providerSizeValue ?? `${size.width}x${size.height}`)),
|
||||||
|
...withSignal(context.signal ?? abortSignal(context.timeoutMs))
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await safeJson(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw providerHttpError(this.provider, response.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = normalizeImageAssets(body, { width: input.width ?? size.width, height: input.height ?? size.height });
|
||||||
|
return {
|
||||||
|
provider: this.provider,
|
||||||
|
model: input.model,
|
||||||
|
assets,
|
||||||
|
usage: normalizeUsage(body, assets.length),
|
||||||
|
rawMetadata: body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeError(error: unknown): ProviderError {
|
||||||
|
return normalizeProviderError(this.provider, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected resolveUrl(config: ProviderEndpointConfig): string {
|
||||||
|
return joinUrl(config.baseUrl ?? this.defaultBaseUrl, config.endpointPath ?? this.defaultEndpointPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildHeaders(auth: { apiKey?: string; bearerToken?: string; headers?: Record<string, string> }, defaultHeaders?: Record<string, string>): Record<string, string> {
|
||||||
|
const token = auth.bearerToken ?? auth.apiKey;
|
||||||
|
return {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...defaultHeaders,
|
||||||
|
...auth.headers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected testPayloadExtras(): Record<string, unknown> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: input.model,
|
||||||
|
prompt: input.prompt,
|
||||||
|
n: input.count ?? 1,
|
||||||
|
size: providerSizeValue,
|
||||||
|
...input.params
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
|
||||||
|
if (input.seed !== undefined) body.seed = input.seed;
|
||||||
|
if (input.responseFormat) {
|
||||||
|
body.response_format = input.responseFormat === "base64" ? "b64_json" : input.responseFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeJson(response: Response): Promise<unknown> {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { message: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortSignal(timeoutMs?: number): AbortSignal | undefined {
|
||||||
|
if (!timeoutMs) return undefined;
|
||||||
|
return AbortSignal.timeout(timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSignal(signal: AbortSignal | undefined): Pick<RequestInit, "signal"> {
|
||||||
|
return signal ? { signal } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withModel(model: string | undefined): Pick<TestConnectionResult, "model"> {
|
||||||
|
return model ? { model } : {};
|
||||||
|
}
|
||||||
104
packages/provider-adapters/src/asset-normalizer.ts
Normal file
104
packages/provider-adapters/src/asset-normalizer.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { GeneratedImageAsset, ProviderUsage } from "./types";
|
||||||
|
|
||||||
|
type ProviderImageRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function normalizeImageAssets(payload: unknown, fallbackSize?: { width?: number; height?: number }): GeneratedImageAsset[] {
|
||||||
|
const data = getImageRecords(payload);
|
||||||
|
|
||||||
|
return data.map((item) => {
|
||||||
|
const url = pickString(item, ["url", "image_url", "imageUrl", "uri"]);
|
||||||
|
const b64 = pickString(item, ["b64_json", "base64", "image_base64", "image"]);
|
||||||
|
const width = pickNumber(item, ["width"]) ?? fallbackSize?.width;
|
||||||
|
const height = pickNumber(item, ["height"]) ?? fallbackSize?.height;
|
||||||
|
const seed = pickNumber(item, ["seed"]);
|
||||||
|
const mimeType = pickString(item, ["mime_type", "mimeType"]);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return {
|
||||||
|
kind: "url",
|
||||||
|
value: url,
|
||||||
|
...withOptionalAssetFields({ width, height, seed, mimeType })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (b64) {
|
||||||
|
return {
|
||||||
|
kind: "base64",
|
||||||
|
value: stripDataUrlPrefix(b64),
|
||||||
|
...withOptionalAssetFields({ width, height, seed, mimeType: mimeType ?? mimeFromDataUrl(b64) })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Provider response did not include a URL or base64 image asset");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUsage(payload: unknown, imageCount: number): ProviderUsage {
|
||||||
|
if (!payload || typeof payload !== "object") return { imageCount };
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const usage = record.usage && typeof record.usage === "object" ? (record.usage as Record<string, unknown>) : record;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageCount,
|
||||||
|
...withOptionalUsageFields({
|
||||||
|
inputTokens: pickNumber(usage, ["input_tokens", "prompt_tokens", "inputTokens"]),
|
||||||
|
outputTokens: pickNumber(usage, ["output_tokens", "completion_tokens", "outputTokens"]),
|
||||||
|
providerCost: pickNumber(usage, ["cost", "providerCost"])
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageRecords(payload: unknown): ProviderImageRecord[] {
|
||||||
|
if (!payload || typeof payload !== "object") return [];
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const data = record.data ?? record.images ?? record.output;
|
||||||
|
if (Array.isArray(data)) return data.filter((item): item is ProviderImageRecord => !!item && typeof item === "object");
|
||||||
|
if (record.url || record.b64_json || record.base64 || record.image) return [record];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickString(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string" && value.length > 0) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDataUrlPrefix(value: string): string {
|
||||||
|
const match = value.match(/^data:[^;]+;base64,(.*)$/);
|
||||||
|
return match?.[1] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeFromDataUrl(value: string): string | undefined {
|
||||||
|
const match = value.match(/^data:([^;]+);base64,/);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptionalAssetFields = {
|
||||||
|
width?: number | undefined;
|
||||||
|
height?: number | undefined;
|
||||||
|
seed?: number | undefined;
|
||||||
|
mimeType?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function withOptionalAssetFields(fields: OptionalAssetFields): Omit<GeneratedImageAsset, "kind" | "value"> {
|
||||||
|
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Omit<GeneratedImageAsset, "kind" | "value">;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptionalUsageFields = {
|
||||||
|
inputTokens?: number | undefined;
|
||||||
|
outputTokens?: number | undefined;
|
||||||
|
providerCost?: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function withOptionalUsageFields(fields: OptionalUsageFields): Omit<ProviderUsage, "imageCount"> {
|
||||||
|
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Omit<ProviderUsage, "imageCount">;
|
||||||
|
}
|
||||||
183
packages/provider-adapters/src/capabilities/presets.ts
Normal file
183
packages/provider-adapters/src/capabilities/presets.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import type { AspectRatio, ImageSizePreset, ModelCapability, ResolutionTier } from "@wallmuse/shared";
|
||||||
|
import type { ProviderSizeRequest } from "../types";
|
||||||
|
|
||||||
|
const WALLPAPER_PRESETS: ImageSizePreset[] = [
|
||||||
|
{ aspectRatio: "16:9", resolution: "1k", width: 1280, height: 720, providerSizeValue: "1280x720", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "9:16", resolution: "1k", width: 720, height: 1280, providerSizeValue: "720x1280", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "16:9", resolution: "2k", width: 2560, height: 1440, providerSizeValue: "2560x1440", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "9:16", resolution: "2k", width: 1440, height: 2560, providerSizeValue: "1440x2560", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "16:9", resolution: "4k", width: 3840, height: 2160, providerSizeValue: "3840x2160", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "9:16", resolution: "4k", width: 2160, height: 3840, providerSizeValue: "2160x3840", native: true, requiresUpscale: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPENAI_IMAGE_PRESETS: ImageSizePreset[] = [
|
||||||
|
{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "9:16", resolution: "1k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "16:9", resolution: "1k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: true, requiresUpscale: false },
|
||||||
|
{ aspectRatio: "16:9", resolution: "2k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: false, requiresUpscale: true },
|
||||||
|
{ aspectRatio: "9:16", resolution: "2k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: false, requiresUpscale: true },
|
||||||
|
{ aspectRatio: "16:9", resolution: "4k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: false, requiresUpscale: true },
|
||||||
|
{ aspectRatio: "9:16", resolution: "4k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: false, requiresUpscale: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SILICONFLOW_PRESETS: ImageSizePreset[] = [
|
||||||
|
...WALLPAPER_PRESETS.filter((preset) => preset.resolution !== "4k"),
|
||||||
|
{ aspectRatio: "16:9", resolution: "4k", width: 2560, height: 1440, providerSizeValue: "2560x1440", native: false, requiresUpscale: true },
|
||||||
|
{ aspectRatio: "9:16", resolution: "4k", width: 1440, height: 2560, providerSizeValue: "1440x2560", native: false, requiresUpscale: true },
|
||||||
|
{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockModelCapabilities: Record<string, ModelCapability> = {
|
||||||
|
"mock-wallpaper": capability({
|
||||||
|
supportsTextToImage: true,
|
||||||
|
supportsImageToImage: true,
|
||||||
|
supportsEdit: true,
|
||||||
|
supportsNegativePrompt: true,
|
||||||
|
supportsSeed: true,
|
||||||
|
supportsBatch: true,
|
||||||
|
supportsBase64Result: true,
|
||||||
|
supportsUrlResult: true,
|
||||||
|
supportsNative4k: true,
|
||||||
|
maxBatchSize: 8,
|
||||||
|
maxInputImages: 3,
|
||||||
|
maxPixels: 8294400,
|
||||||
|
sizePresets: [...WALLPAPER_PRESETS, { aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false }]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openAiCompatibleModelCapabilities: Record<string, ModelCapability> = {
|
||||||
|
"gpt-image-1": capability({
|
||||||
|
supportsTextToImage: true,
|
||||||
|
supportsImageToImage: true,
|
||||||
|
supportsEdit: true,
|
||||||
|
supportsNegativePrompt: false,
|
||||||
|
supportsSeed: false,
|
||||||
|
supportsBatch: true,
|
||||||
|
supportsBase64Result: true,
|
||||||
|
supportsUrlResult: true,
|
||||||
|
supportsNative4k: false,
|
||||||
|
maxBatchSize: 4,
|
||||||
|
maxInputImages: 3,
|
||||||
|
maxPixels: 1572864,
|
||||||
|
sizePresets: OPENAI_IMAGE_PRESETS
|
||||||
|
}),
|
||||||
|
default: capability({
|
||||||
|
supportsTextToImage: true,
|
||||||
|
supportsImageToImage: false,
|
||||||
|
supportsEdit: false,
|
||||||
|
supportsNegativePrompt: true,
|
||||||
|
supportsSeed: true,
|
||||||
|
supportsBatch: true,
|
||||||
|
supportsBase64Result: true,
|
||||||
|
supportsUrlResult: true,
|
||||||
|
supportsNative4k: false,
|
||||||
|
maxBatchSize: 4,
|
||||||
|
maxPixels: 2097152,
|
||||||
|
sizePresets: SILICONFLOW_PRESETS
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const siliconFlowModelCapabilities: Record<string, ModelCapability> = {
|
||||||
|
"black-forest-labs/FLUX.2-pro": capability({
|
||||||
|
supportsTextToImage: true,
|
||||||
|
supportsImageToImage: false,
|
||||||
|
supportsEdit: false,
|
||||||
|
supportsNegativePrompt: true,
|
||||||
|
supportsSeed: true,
|
||||||
|
supportsBatch: false,
|
||||||
|
supportsBase64Result: false,
|
||||||
|
supportsUrlResult: true,
|
||||||
|
supportsNative4k: false,
|
||||||
|
maxBatchSize: 1,
|
||||||
|
maxPixels: 3686400,
|
||||||
|
sizePresets: SILICONFLOW_PRESETS
|
||||||
|
}),
|
||||||
|
"Qwen/Qwen-Image": capability({
|
||||||
|
supportsTextToImage: true,
|
||||||
|
supportsImageToImage: false,
|
||||||
|
supportsEdit: false,
|
||||||
|
supportsNegativePrompt: true,
|
||||||
|
supportsSeed: true,
|
||||||
|
supportsBatch: false,
|
||||||
|
supportsBase64Result: false,
|
||||||
|
supportsUrlResult: true,
|
||||||
|
supportsNative4k: false,
|
||||||
|
maxBatchSize: 1,
|
||||||
|
maxPixels: 4194304,
|
||||||
|
sizePresets: SILICONFLOW_PRESETS
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const placeholderCapabilities: Record<string, ModelCapability> = {
|
||||||
|
default: capability({
|
||||||
|
supportsTextToImage: false,
|
||||||
|
supportsImageToImage: false,
|
||||||
|
supportsEdit: false,
|
||||||
|
supportsNegativePrompt: false,
|
||||||
|
supportsSeed: false,
|
||||||
|
supportsBatch: false,
|
||||||
|
supportsBase64Result: false,
|
||||||
|
supportsUrlResult: false,
|
||||||
|
supportsNative4k: false,
|
||||||
|
maxBatchSize: 1,
|
||||||
|
maxPixels: 1048576,
|
||||||
|
sizePresets: [{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, native: false, requiresUpscale: false }]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveModelCapability(
|
||||||
|
capabilities: Record<string, ModelCapability>,
|
||||||
|
model: string,
|
||||||
|
override?: ModelCapability
|
||||||
|
): ModelCapability {
|
||||||
|
return override ?? capabilities[model] ?? capabilities.default ?? Object.values(capabilities)[0] ?? placeholderCapabilities.default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSize(
|
||||||
|
capability: ModelCapability,
|
||||||
|
request?: ProviderSizeRequest
|
||||||
|
): ImageSizePreset {
|
||||||
|
const aspectRatio = request?.aspectRatio ?? "1:1";
|
||||||
|
const resolution = request?.resolution ?? "1k";
|
||||||
|
const exact = capability.sizePresets.find((preset) => matchesPreset(preset, aspectRatio, resolution));
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
const sameAspect = capability.sizePresets.find((preset) => preset.aspectRatio === aspectRatio);
|
||||||
|
if (sameAspect) return sameAspect;
|
||||||
|
|
||||||
|
return capability.sizePresets[0] ?? placeholderCapabilities.default!.sizePresets[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CapabilityInput = Omit<
|
||||||
|
ModelCapability,
|
||||||
|
| "supportedAspectRatios"
|
||||||
|
| "supportedResolutions"
|
||||||
|
| "defaultParams"
|
||||||
|
| "maxPromptLength"
|
||||||
|
| "maxNegativePromptLength"
|
||||||
|
| "supportsStreaming"
|
||||||
|
| "maxInputImages"
|
||||||
|
> &
|
||||||
|
Partial<Pick<ModelCapability, "maxPromptLength" | "maxNegativePromptLength" | "defaultParams" | "supportsStreaming" | "maxInputImages">>;
|
||||||
|
|
||||||
|
function capability(input: CapabilityInput): ModelCapability {
|
||||||
|
const sizePresets = input.sizePresets;
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
supportsStreaming: input.supportsStreaming ?? false,
|
||||||
|
maxInputImages: input.maxInputImages ?? 0,
|
||||||
|
maxPromptLength: input.maxPromptLength ?? 4000,
|
||||||
|
maxNegativePromptLength: input.maxNegativePromptLength ?? 2000,
|
||||||
|
supportedAspectRatios: unique(sizePresets.map((preset) => preset.aspectRatio)),
|
||||||
|
supportedResolutions: unique(sizePresets.map((preset) => preset.resolution)),
|
||||||
|
defaultParams: input.defaultParams ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPreset(preset: ImageSizePreset, aspectRatio: AspectRatio, resolution: ResolutionTier): boolean {
|
||||||
|
return preset.aspectRatio === aspectRatio && preset.resolution === resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique<T extends string>(values: T[]): T[] {
|
||||||
|
return Array.from(new Set(values));
|
||||||
|
}
|
||||||
137
packages/provider-adapters/src/errors.ts
Normal file
137
packages/provider-adapters/src/errors.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ProviderCode, ProviderError, ProviderErrorCategory } from "./types";
|
||||||
|
|
||||||
|
type ErrorLike = {
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
statusCode?: number;
|
||||||
|
code?: string;
|
||||||
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
cause?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class NormalizedProviderError extends Error {
|
||||||
|
readonly providerError: ProviderError;
|
||||||
|
|
||||||
|
constructor(providerError: ProviderError) {
|
||||||
|
super(providerError.message);
|
||||||
|
this.name = "NormalizedProviderError";
|
||||||
|
this.providerError = providerError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProviderError(provider: ProviderCode | string, error: unknown): ProviderError {
|
||||||
|
if (error instanceof NormalizedProviderError) {
|
||||||
|
return error.providerError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = getStatusCode(error);
|
||||||
|
const providerCode = getProviderErrorCode(error, statusCode);
|
||||||
|
const message = getProviderErrorMessage(error);
|
||||||
|
const category = categorizeProviderError(statusCode, providerCode, message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
category,
|
||||||
|
code: providerCode,
|
||||||
|
message,
|
||||||
|
retryable: isRetryable(category, statusCode),
|
||||||
|
...(statusCode !== undefined ? { statusCode } : {}),
|
||||||
|
raw: error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerHttpError(provider: ProviderCode | string, statusCode: number, body: unknown): ProviderError {
|
||||||
|
const code = getBodyErrorCode(body) ?? `HTTP_${statusCode}`;
|
||||||
|
const message = getBodyErrorMessage(body) ?? `Provider request failed with HTTP ${statusCode}`;
|
||||||
|
const category = categorizeProviderError(statusCode, code, message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
category,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
statusCode,
|
||||||
|
retryable: isRetryable(category, statusCode),
|
||||||
|
raw: body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusCode(error: unknown): number | undefined {
|
||||||
|
const value = error as ErrorLike;
|
||||||
|
return value?.statusCode ?? value?.status ?? value?.response?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderErrorCode(error: unknown, statusCode?: number): string {
|
||||||
|
const value = error as ErrorLike;
|
||||||
|
const fromBody = getBodyErrorCode(value?.response?.data) ?? getBodyErrorCode(error);
|
||||||
|
return fromBody ?? value?.code ?? (statusCode ? `HTTP_${statusCode}` : "PROVIDER_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderErrorMessage(error: unknown): string {
|
||||||
|
const value = error as ErrorLike;
|
||||||
|
return getBodyErrorMessage(value?.response?.data) ?? getBodyErrorMessage(error) ?? value?.message ?? "Provider request failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBodyErrorCode(body: unknown): string | undefined {
|
||||||
|
if (!body || typeof body !== "object") return undefined;
|
||||||
|
const record = body as Record<string, unknown>;
|
||||||
|
const nested = record.error && typeof record.error === "object" ? (record.error as Record<string, unknown>) : undefined;
|
||||||
|
const code = nested?.code ?? nested?.type ?? record.code ?? record.error_code;
|
||||||
|
return typeof code === "string" ? code : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBodyErrorMessage(body: unknown): string | undefined {
|
||||||
|
if (!body || typeof body !== "object") return undefined;
|
||||||
|
const record = body as Record<string, unknown>;
|
||||||
|
const nested = record.error && typeof record.error === "object" ? (record.error as Record<string, unknown>) : undefined;
|
||||||
|
const message = nested?.message ?? record.message ?? record.error_msg ?? record.detail;
|
||||||
|
return typeof message === "string" ? message : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorizeProviderError(statusCode: number | undefined, code: string, message: string): ProviderErrorCategory {
|
||||||
|
const normalized = `${code} ${message}`.toLowerCase();
|
||||||
|
|
||||||
|
if (statusCode === 401 || normalized.includes("invalid api key") || normalized.includes("unauthorized")) {
|
||||||
|
return "authentication";
|
||||||
|
}
|
||||||
|
if (statusCode === 403 || normalized.includes("permission") || normalized.includes("forbidden")) {
|
||||||
|
return normalized.includes("policy") || normalized.includes("safety") ? "content_policy" : "permission";
|
||||||
|
}
|
||||||
|
if (statusCode === 408 || normalized.includes("timeout") || normalized.includes("aborted")) {
|
||||||
|
return "timeout";
|
||||||
|
}
|
||||||
|
if (statusCode === 429 || normalized.includes("rate limit") || normalized.includes("too many request")) {
|
||||||
|
return "rate_limit";
|
||||||
|
}
|
||||||
|
if (normalized.includes("quota") || normalized.includes("insufficient") || normalized.includes("balance")) {
|
||||||
|
return "quota";
|
||||||
|
}
|
||||||
|
if (normalized.includes("content policy") || normalized.includes("safety") || normalized.includes("sensitive")) {
|
||||||
|
return "content_policy";
|
||||||
|
}
|
||||||
|
if (statusCode === 400 || statusCode === 422) {
|
||||||
|
return "bad_request";
|
||||||
|
}
|
||||||
|
if (statusCode === 404) {
|
||||||
|
return "not_found";
|
||||||
|
}
|
||||||
|
if (statusCode && statusCode >= 500) {
|
||||||
|
return "provider_unavailable";
|
||||||
|
}
|
||||||
|
if (normalized.includes("fetch failed") || normalized.includes("econn") || normalized.includes("network")) {
|
||||||
|
return "network";
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusCode ? "provider_error" : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryable(category: ProviderErrorCategory, statusCode?: number): boolean {
|
||||||
|
if (category === "rate_limit" || category === "timeout" || category === "network" || category === "provider_unavailable") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return typeof statusCode === "number" && statusCode >= 500;
|
||||||
|
}
|
||||||
16
packages/provider-adapters/src/index.ts
Normal file
16
packages/provider-adapters/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export * from "./types.js";
|
||||||
|
export * from "./errors.js";
|
||||||
|
export * from "./asset-normalizer.js";
|
||||||
|
export * from "./capabilities/presets.js";
|
||||||
|
export * from "./providers/mock.js";
|
||||||
|
export * from "./providers/openai-compatible.js";
|
||||||
|
export * from "./providers/siliconflow.js";
|
||||||
|
export * from "./providers/placeholders.js";
|
||||||
|
export * from "./registry.js";
|
||||||
|
|
||||||
|
import { createImageProviderAdapter } from "./registry.js";
|
||||||
|
import type { ImageProviderAdapter, ProviderCode } from "./types.js";
|
||||||
|
|
||||||
|
export function getImageProviderAdapter(provider: string): ImageProviderAdapter {
|
||||||
|
return createImageProviderAdapter(provider as ProviderCode);
|
||||||
|
}
|
||||||
70
packages/provider-adapters/src/providers/mock.ts
Normal file
70
packages/provider-adapters/src/providers/mock.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { mockModelCapabilities, resolveModelCapability, resolveSize } from "../capabilities/presets";
|
||||||
|
import type {
|
||||||
|
ImageGenerationResult,
|
||||||
|
ImageProviderAdapter,
|
||||||
|
ImageToImageInput,
|
||||||
|
ProviderCallContext,
|
||||||
|
ProviderError,
|
||||||
|
TestConnectionInput,
|
||||||
|
TestConnectionResult,
|
||||||
|
TextToImageInput
|
||||||
|
} from "../types";
|
||||||
|
import { normalizeProviderError } from "../errors";
|
||||||
|
|
||||||
|
const ONE_PIXEL_PNG =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
export class MockImageProviderAdapter implements ImageProviderAdapter {
|
||||||
|
readonly provider = "mock" as const;
|
||||||
|
readonly capabilities = mockModelCapabilities;
|
||||||
|
|
||||||
|
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
provider: this.provider,
|
||||||
|
model: input.model ?? "mock-wallpaper",
|
||||||
|
latencyMs: 0,
|
||||||
|
rawMetadata: { mode: "mock" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTextToImage(input: TextToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||||
|
return this.generate(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateImageToImage(input: ImageToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||||
|
return this.generate(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeError(error: unknown): ProviderError {
|
||||||
|
return normalizeProviderError(this.provider, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generate(input: TextToImageInput): ImageGenerationResult {
|
||||||
|
const capability = resolveModelCapability(this.capabilities, input.model, input.capability);
|
||||||
|
const size = resolveSize(capability, input.size);
|
||||||
|
const count = input.count ?? 1;
|
||||||
|
const responseAsBase64 = input.responseFormat === "base64" || input.responseFormat === "b64_json";
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: this.provider,
|
||||||
|
model: input.model,
|
||||||
|
assets: Array.from({ length: count }, (_, index) =>
|
||||||
|
({
|
||||||
|
kind: responseAsBase64 ? "base64" : "url",
|
||||||
|
value: responseAsBase64 ? ONE_PIXEL_PNG : `mock://wallmuse/${input.model}/${size.width}x${size.height}/${input.seed ?? "auto"}-${index}.png`,
|
||||||
|
mimeType: "image/png",
|
||||||
|
width: input.width ?? size.width,
|
||||||
|
height: input.height ?? size.height,
|
||||||
|
...(input.seed !== undefined ? { seed: input.seed } : {})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
usage: { imageCount: count },
|
||||||
|
rawMetadata: {
|
||||||
|
prompt: input.prompt,
|
||||||
|
negativePrompt: input.negativePrompt,
|
||||||
|
size
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseHttpImageAdapter } from "../adapters/base-http-adapter";
|
||||||
|
import { openAiCompatibleModelCapabilities } from "../capabilities/presets";
|
||||||
|
import type { TextToImageInput } from "../types";
|
||||||
|
|
||||||
|
export class OpenAICompatibleImageProviderAdapter extends BaseHttpImageAdapter {
|
||||||
|
readonly provider = "openai-compatible" as const;
|
||||||
|
readonly capabilities = openAiCompatibleModelCapabilities;
|
||||||
|
protected readonly defaultBaseUrl = "https://api.openai.com";
|
||||||
|
protected readonly defaultEndpointPath = "/v1/images/generations";
|
||||||
|
|
||||||
|
protected override toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
|
||||||
|
const body = super.toRequestBody(input, providerSizeValue);
|
||||||
|
|
||||||
|
if (input.quality) {
|
||||||
|
body.quality = input.quality === "ultra" ? "hd" : input.quality;
|
||||||
|
}
|
||||||
|
if (input.width && input.height) {
|
||||||
|
body.size = `${input.width}x${input.height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
packages/provider-adapters/src/providers/placeholders.ts
Normal file
50
packages/provider-adapters/src/providers/placeholders.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { placeholderCapabilities } from "../capabilities/presets";
|
||||||
|
import { normalizeProviderError } from "../errors";
|
||||||
|
import type {
|
||||||
|
ImageGenerationResult,
|
||||||
|
ImageProviderAdapter,
|
||||||
|
ProviderCallContext,
|
||||||
|
ProviderCode,
|
||||||
|
ProviderError,
|
||||||
|
TestConnectionInput,
|
||||||
|
TestConnectionResult,
|
||||||
|
TextToImageInput
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export class ReservedImageProviderAdapter implements ImageProviderAdapter {
|
||||||
|
readonly capabilities = placeholderCapabilities;
|
||||||
|
|
||||||
|
constructor(readonly provider: Extract<ProviderCode, "dashscope" | "volcengine" | "zhipu" | "custom-template">) {}
|
||||||
|
|
||||||
|
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
provider: this.provider,
|
||||||
|
...(input.model ? { model: input.model } : {}),
|
||||||
|
error: {
|
||||||
|
provider: this.provider,
|
||||||
|
category: "provider_error",
|
||||||
|
code: "ADAPTER_RESERVED",
|
||||||
|
message: `${this.provider} adapter is reserved but not implemented yet`,
|
||||||
|
retryable: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTextToImage(_input: TextToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
|
||||||
|
throw this.normalizeError(new Error(`${this.provider} adapter is reserved but not implemented yet`));
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeError(error: unknown): ProviderError {
|
||||||
|
const normalized = normalizeProviderError(this.provider, error);
|
||||||
|
if (normalized.code === "PROVIDER_ERROR") {
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
category: "provider_error",
|
||||||
|
code: "ADAPTER_RESERVED",
|
||||||
|
retryable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/provider-adapters/src/providers/siliconflow.ts
Normal file
29
packages/provider-adapters/src/providers/siliconflow.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { BaseHttpImageAdapter } from "../adapters/base-http-adapter";
|
||||||
|
import { siliconFlowModelCapabilities } from "../capabilities/presets";
|
||||||
|
import type { TextToImageInput } from "../types";
|
||||||
|
|
||||||
|
export class SiliconFlowImageProviderAdapter extends BaseHttpImageAdapter {
|
||||||
|
readonly provider = "siliconflow" as const;
|
||||||
|
readonly capabilities = siliconFlowModelCapabilities;
|
||||||
|
protected readonly defaultBaseUrl = "https://api.siliconflow.cn";
|
||||||
|
protected readonly defaultEndpointPath = "/v1/images/generations";
|
||||||
|
|
||||||
|
protected override testPayloadExtras(): Record<string, unknown> {
|
||||||
|
return { image_size: "1024x1024" };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: input.model,
|
||||||
|
prompt: input.prompt,
|
||||||
|
image_size: input.width && input.height ? `${input.width}x${input.height}` : providerSizeValue,
|
||||||
|
batch_size: input.count ?? 1,
|
||||||
|
...input.params
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
|
||||||
|
if (input.seed !== undefined) body.seed = input.seed;
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/provider-adapters/src/registry.ts
Normal file
33
packages/provider-adapters/src/registry.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { MockImageProviderAdapter } from "./providers/mock";
|
||||||
|
import { OpenAICompatibleImageProviderAdapter } from "./providers/openai-compatible";
|
||||||
|
import { ReservedImageProviderAdapter } from "./providers/placeholders";
|
||||||
|
import { SiliconFlowImageProviderAdapter } from "./providers/siliconflow";
|
||||||
|
import type { ImageProviderAdapter, ProviderCode } from "./types";
|
||||||
|
|
||||||
|
export function createImageProviderAdapter(provider: ProviderCode): ImageProviderAdapter {
|
||||||
|
switch (provider) {
|
||||||
|
case "mock":
|
||||||
|
return new MockImageProviderAdapter();
|
||||||
|
case "openai-compatible":
|
||||||
|
return new OpenAICompatibleImageProviderAdapter();
|
||||||
|
case "siliconflow":
|
||||||
|
return new SiliconFlowImageProviderAdapter();
|
||||||
|
case "dashscope":
|
||||||
|
case "volcengine":
|
||||||
|
case "zhipu":
|
||||||
|
case "custom-template":
|
||||||
|
return new ReservedImageProviderAdapter(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultProviderRegistry(): Record<ProviderCode, ImageProviderAdapter> {
|
||||||
|
return {
|
||||||
|
mock: createImageProviderAdapter("mock"),
|
||||||
|
"openai-compatible": createImageProviderAdapter("openai-compatible"),
|
||||||
|
siliconflow: createImageProviderAdapter("siliconflow"),
|
||||||
|
dashscope: createImageProviderAdapter("dashscope"),
|
||||||
|
volcengine: createImageProviderAdapter("volcengine"),
|
||||||
|
zhipu: createImageProviderAdapter("zhipu"),
|
||||||
|
"custom-template": createImageProviderAdapter("custom-template")
|
||||||
|
};
|
||||||
|
}
|
||||||
144
packages/provider-adapters/src/types.ts
Normal file
144
packages/provider-adapters/src/types.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { AspectRatio, ModelCapability, ResolutionTier } from "@wallmuse/shared";
|
||||||
|
|
||||||
|
export type ProviderCode =
|
||||||
|
| "mock"
|
||||||
|
| "openai-compatible"
|
||||||
|
| "siliconflow"
|
||||||
|
| "dashscope"
|
||||||
|
| "volcengine"
|
||||||
|
| "zhipu"
|
||||||
|
| "custom-template";
|
||||||
|
|
||||||
|
export type ProviderAuth = {
|
||||||
|
apiKey?: string;
|
||||||
|
bearerToken?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderCallContext = {
|
||||||
|
requestId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
auth?: ProviderAuth;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderEndpointConfig = {
|
||||||
|
baseUrl?: string;
|
||||||
|
endpointPath?: string;
|
||||||
|
defaultHeaders?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestConnectionInput = ProviderEndpointConfig & {
|
||||||
|
model?: string;
|
||||||
|
auth?: ProviderAuth;
|
||||||
|
timeoutMs?: number;
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestConnectionResult = {
|
||||||
|
ok: boolean;
|
||||||
|
provider: ProviderCode;
|
||||||
|
latencyMs?: number;
|
||||||
|
model?: string;
|
||||||
|
error?: ProviderError;
|
||||||
|
rawMetadata?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageReference = {
|
||||||
|
kind: "url" | "base64";
|
||||||
|
value: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderSizeRequest = {
|
||||||
|
aspectRatio: AspectRatio;
|
||||||
|
resolution: ResolutionTier;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextToImageInput = ProviderEndpointConfig & {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
size?: ProviderSizeRequest;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
count?: number;
|
||||||
|
seed?: number;
|
||||||
|
quality?: "standard" | "hd" | "ultra";
|
||||||
|
responseFormat?: "url" | "base64" | "b64_json";
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
capability?: ModelCapability;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageToImageInput = TextToImageInput & {
|
||||||
|
images: ImageReference[];
|
||||||
|
strength?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeneratedImageAsset = {
|
||||||
|
kind: "url" | "base64";
|
||||||
|
value: string;
|
||||||
|
mimeType?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
seed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderUsage = {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
imageCount?: number;
|
||||||
|
providerCost?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageGenerationResult = {
|
||||||
|
provider: ProviderCode | string;
|
||||||
|
model: string;
|
||||||
|
assets: GeneratedImageAsset[];
|
||||||
|
usage?: ProviderUsage;
|
||||||
|
rawMetadata?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderErrorCategory =
|
||||||
|
| "authentication"
|
||||||
|
| "permission"
|
||||||
|
| "rate_limit"
|
||||||
|
| "quota"
|
||||||
|
| "timeout"
|
||||||
|
| "bad_request"
|
||||||
|
| "not_found"
|
||||||
|
| "provider_unavailable"
|
||||||
|
| "provider_error"
|
||||||
|
| "content_policy"
|
||||||
|
| "network"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type ProviderError = {
|
||||||
|
provider: ProviderCode | string;
|
||||||
|
category: ProviderErrorCategory;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
statusCode?: number;
|
||||||
|
retryable: boolean;
|
||||||
|
raw?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ImageProviderAdapter {
|
||||||
|
readonly provider: ProviderCode;
|
||||||
|
readonly capabilities: Record<string, ModelCapability>;
|
||||||
|
|
||||||
|
testConnection(input: TestConnectionInput): Promise<TestConnectionResult>;
|
||||||
|
|
||||||
|
generateTextToImage(input: TextToImageInput, context: ProviderCallContext): Promise<ImageGenerationResult>;
|
||||||
|
|
||||||
|
generateImageToImage?(
|
||||||
|
input: ImageToImageInput,
|
||||||
|
context: ProviderCallContext
|
||||||
|
): Promise<ImageGenerationResult>;
|
||||||
|
|
||||||
|
normalizeError(error: unknown): ProviderError;
|
||||||
|
}
|
||||||
14
packages/provider-adapters/src/url.ts
Normal file
14
packages/provider-adapters/src/url.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export function joinUrl(baseUrl: string, endpointPath: string): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
const path = endpointPath.replace(/^\/+/, "");
|
||||||
|
return `${base}/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withDefaultImageEndpoint(baseUrl: string | undefined, endpointPath = "/v1/images/generations"): string {
|
||||||
|
return joinUrl(baseUrl ?? "https://api.openai.com", endpointPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureV1BaseUrl(baseUrl: string): string {
|
||||||
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||||
|
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
||||||
|
}
|
||||||
10
packages/provider-adapters/tsconfig.json
Normal file
10
packages/provider-adapters/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "../..",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
21
packages/shared/package.json
Executable file
21
packages/shared/package.json
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"build": "tsc -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.24.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/shared/src/api-paths.ts
Executable file
9
packages/shared/src/api-paths.ts
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
export const API_V1_PREFIX = "/api/v1" as const;
|
||||||
|
|
||||||
|
export const apiPaths = {
|
||||||
|
appConfig: `${API_V1_PREFIX}/app/config`,
|
||||||
|
providers: `${API_V1_PREFIX}/providers`,
|
||||||
|
models: `${API_V1_PREFIX}/models`,
|
||||||
|
generations: `${API_V1_PREFIX}/generations`,
|
||||||
|
generationGroup: (id: string) => `${API_V1_PREFIX}/generation-groups/${id}`
|
||||||
|
} as const;
|
||||||
101
packages/shared/src/dto/api-management.ts
Executable file
101
packages/shared/src/dto/api-management.ts
Executable file
@@ -0,0 +1,101 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ApiKeyModeSchema, ModelCapabilitySchema, ModelPricingSchema, ModelStatusSchema, ProviderStatusSchema } from "../model-capability";
|
||||||
|
|
||||||
|
export const UserRoleSchema = z.enum(["user", "admin", "super_admin"]);
|
||||||
|
|
||||||
|
export const PublicUserSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
roles: z.array(UserRoleSchema),
|
||||||
|
createdAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RegisterRequestSchema = z.object({
|
||||||
|
email: z.string().email().max(255),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
name: z.string().min(1).max(80).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LoginRequestSchema = z.object({
|
||||||
|
email: z.string().email().max(255),
|
||||||
|
password: z.string().min(1).max(128)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthResponseSchema = z.object({
|
||||||
|
user: PublicUserSchema,
|
||||||
|
token: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateUserApiKeyRequestSchema = z.object({
|
||||||
|
providerId: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(80),
|
||||||
|
apiKey: z.string().min(6).max(4096),
|
||||||
|
baseUrl: z.string().url().optional(),
|
||||||
|
defaultModelId: z.string().uuid().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserApiKeyResponseSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
providerId: z.string().uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
maskedKey: z.string(),
|
||||||
|
baseUrl: z.string().url().optional(),
|
||||||
|
defaultModelId: z.string().uuid().optional(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
updatedAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateProviderRequestSchema = z.object({
|
||||||
|
slug: z.string().min(2).max(80).regex(/^[a-z0-9][a-z0-9-_]*$/),
|
||||||
|
displayName: z.string().min(1).max(120),
|
||||||
|
baseUrl: z.string().url().optional(),
|
||||||
|
authType: z.enum(["bearer", "api_key", "custom"]).default("bearer"),
|
||||||
|
status: ProviderStatusSchema.default("healthy"),
|
||||||
|
keyMode: ApiKeyModeSchema.default("hybrid"),
|
||||||
|
supportsUserKeys: z.boolean().default(true),
|
||||||
|
supportsPlatformKeys: z.boolean().default(true),
|
||||||
|
healthCheckPath: z.string().max(255).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateProviderRequestSchema = CreateProviderRequestSchema.partial().omit({ slug: true });
|
||||||
|
|
||||||
|
export const CreateModelRequestSchema = z.object({
|
||||||
|
providerId: z.string().uuid(),
|
||||||
|
slug: z.string().min(1).max(160),
|
||||||
|
displayName: z.string().min(1).max(160),
|
||||||
|
status: ModelStatusSchema.default("enabled"),
|
||||||
|
keyMode: ApiKeyModeSchema.default("hybrid"),
|
||||||
|
capability: ModelCapabilitySchema,
|
||||||
|
pricing: ModelPricingSchema.optional(),
|
||||||
|
sortOrder: z.number().int().default(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateModelRequestSchema = CreateModelRequestSchema.partial().omit({ providerId: true });
|
||||||
|
|
||||||
|
export const ProviderCallLogSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
taskId: z.string().uuid().optional(),
|
||||||
|
providerId: z.string().uuid(),
|
||||||
|
modelId: z.string().uuid().optional(),
|
||||||
|
status: z.enum(["success", "failed"]),
|
||||||
|
latencyMs: z.number().int().nonnegative().optional(),
|
||||||
|
errorCode: z.string().optional(),
|
||||||
|
errorMessage: z.string().optional(),
|
||||||
|
createdAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserRole = z.infer<typeof UserRoleSchema>;
|
||||||
|
export type PublicUser = z.infer<typeof PublicUserSchema>;
|
||||||
|
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
|
||||||
|
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
||||||
|
export type AuthResponse = z.infer<typeof AuthResponseSchema>;
|
||||||
|
export type CreateUserApiKeyRequest = z.infer<typeof CreateUserApiKeyRequestSchema>;
|
||||||
|
export type UserApiKeyResponse = z.infer<typeof UserApiKeyResponseSchema>;
|
||||||
|
export type CreateProviderRequest = z.infer<typeof CreateProviderRequestSchema>;
|
||||||
|
export type UpdateProviderRequest = z.infer<typeof UpdateProviderRequestSchema>;
|
||||||
|
export type CreateModelRequest = z.infer<typeof CreateModelRequestSchema>;
|
||||||
|
export type UpdateModelRequest = z.infer<typeof UpdateModelRequestSchema>;
|
||||||
|
export type ProviderCallLog = z.infer<typeof ProviderCallLogSchema>;
|
||||||
33
packages/shared/src/dto/app-config.ts
Executable file
33
packages/shared/src/dto/app-config.ts
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AspectRatioSchema, ModelSummarySchema, ProviderSummarySchema, ResolutionTierSchema } from "../model-capability";
|
||||||
|
|
||||||
|
export const AppFeatureFlagsSchema = z.object({
|
||||||
|
authEnabled: z.boolean().default(true),
|
||||||
|
galleryEnabled: z.boolean().default(true),
|
||||||
|
userApiKeysEnabled: z.boolean().default(true),
|
||||||
|
generationEnabled: z.boolean().default(true),
|
||||||
|
darkModeEnabled: z.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AppConfigResponseSchema = z.object({
|
||||||
|
site: z.object({
|
||||||
|
name: z.string().default("WallMuse"),
|
||||||
|
tagline: z.string().optional(),
|
||||||
|
logoUrl: z.string().url().optional(),
|
||||||
|
supportEmail: z.string().email().optional()
|
||||||
|
}),
|
||||||
|
generation: z.object({
|
||||||
|
defaultModelId: z.string().uuid().optional(),
|
||||||
|
defaultAspectRatios: z.array(AspectRatioSchema).min(1).default(["16:9", "9:16"]),
|
||||||
|
defaultResolution: ResolutionTierSchema.default("2k"),
|
||||||
|
maxBatchSize: z.number().int().positive().default(4),
|
||||||
|
allowedResolutions: z.array(ResolutionTierSchema).min(1)
|
||||||
|
}),
|
||||||
|
features: AppFeatureFlagsSchema,
|
||||||
|
providers: z.array(ProviderSummarySchema),
|
||||||
|
models: z.array(ModelSummarySchema),
|
||||||
|
updatedAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppFeatureFlags = z.infer<typeof AppFeatureFlagsSchema>;
|
||||||
|
export type AppConfigResponse = z.infer<typeof AppConfigResponseSchema>;
|
||||||
84
packages/shared/src/dto/generation.ts
Executable file
84
packages/shared/src/dto/generation.ts
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AspectRatioSchema, GenerationModeSchema, GenerationQualitySchema, ResolutionTierSchema } from "../model-capability";
|
||||||
|
import { GenerationGroupStatusSchema, GenerationTaskStatusSchema } from "../status";
|
||||||
|
|
||||||
|
export const AssetKindSchema = z.enum(["reference", "master", "landscape", "portrait", "thumbnail", "preview", "download_zip"]);
|
||||||
|
export const AssetStatusSchema = z.enum(["temporary", "active", "deleted", "failed"]);
|
||||||
|
export const ModerationStatusSchema = z.enum(["pending", "passed", "rejected", "manual_review"]);
|
||||||
|
|
||||||
|
export const CreateGenerationRequestSchema = z.object({
|
||||||
|
mode: GenerationModeSchema,
|
||||||
|
modelId: z.string().uuid(),
|
||||||
|
prompt: z.string().min(1).max(4000),
|
||||||
|
negativePrompt: z.string().max(2000).optional(),
|
||||||
|
aspectRatios: z.array(AspectRatioSchema).min(1).max(3).default(["16:9", "9:16"]),
|
||||||
|
resolution: ResolutionTierSchema.default("2k"),
|
||||||
|
quality: GenerationQualitySchema.default("standard"),
|
||||||
|
batchSize: z.number().int().min(1).max(8).default(1),
|
||||||
|
seed: z.number().int().optional(),
|
||||||
|
referenceAssetId: z.string().uuid().optional(),
|
||||||
|
stylePresetId: z.string().uuid().optional(),
|
||||||
|
userApiKeyId: z.string().uuid().optional(),
|
||||||
|
publishToGallery: z.boolean().default(false),
|
||||||
|
metadata: z.record(z.unknown()).default({})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GenerationTaskSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
groupId: z.string().uuid(),
|
||||||
|
status: GenerationTaskStatusSchema,
|
||||||
|
mode: GenerationModeSchema,
|
||||||
|
aspectRatio: AspectRatioSchema,
|
||||||
|
resolution: ResolutionTierSchema,
|
||||||
|
quality: GenerationQualitySchema,
|
||||||
|
attempt: z.number().int().nonnegative(),
|
||||||
|
maxAttempts: z.number().int().positive(),
|
||||||
|
progress: z.number().int().min(0).max(100),
|
||||||
|
errorCode: z.string().optional(),
|
||||||
|
errorMessage: z.string().optional(),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
updatedAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GeneratedAssetSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
taskId: z.string().uuid().optional(),
|
||||||
|
kind: AssetKindSchema,
|
||||||
|
status: AssetStatusSchema,
|
||||||
|
width: z.number().int().positive().optional(),
|
||||||
|
height: z.number().int().positive().optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
publicUrl: z.string().url().optional(),
|
||||||
|
blurHash: z.string().optional(),
|
||||||
|
createdAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GenerationGroupSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
status: GenerationGroupStatusSchema,
|
||||||
|
modelId: z.string().uuid(),
|
||||||
|
prompt: z.string(),
|
||||||
|
negativePrompt: z.string().optional(),
|
||||||
|
tasks: z.array(GenerationTaskSchema),
|
||||||
|
assets: z.array(GeneratedAssetSchema).default([]),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
updatedAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateGenerationResponseSchema = z.object({
|
||||||
|
generationGroup: GenerationGroupSchema,
|
||||||
|
pollingUrl: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GenerationGroupParamsSchema = z.object({
|
||||||
|
id: z.string().uuid()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AssetKind = z.infer<typeof AssetKindSchema>;
|
||||||
|
export type AssetStatus = z.infer<typeof AssetStatusSchema>;
|
||||||
|
export type ModerationStatus = z.infer<typeof ModerationStatusSchema>;
|
||||||
|
export type CreateGenerationRequest = z.infer<typeof CreateGenerationRequestSchema>;
|
||||||
|
export type GenerationTask = z.infer<typeof GenerationTaskSchema>;
|
||||||
|
export type GeneratedAsset = z.infer<typeof GeneratedAssetSchema>;
|
||||||
|
export type GenerationGroup = z.infer<typeof GenerationGroupSchema>;
|
||||||
|
export type CreateGenerationResponse = z.infer<typeof CreateGenerationResponseSchema>;
|
||||||
92
packages/shared/src/dto/web.ts
Executable file
92
packages/shared/src/dto/web.ts
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AspectRatioSchema, ResolutionTierSchema } from "../model-capability";
|
||||||
|
import { GenerationGroupStatusSchema } from "../status";
|
||||||
|
|
||||||
|
export const ThemePreferenceSchema = z.enum(["system", "light", "dark"]);
|
||||||
|
|
||||||
|
export const WebWallpaperSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
imageUrl: z.string().url(),
|
||||||
|
ratio: AspectRatioSchema,
|
||||||
|
resolution: ResolutionTierSchema,
|
||||||
|
style: z.string(),
|
||||||
|
model: z.string(),
|
||||||
|
likes: z.number().int().nonnegative(),
|
||||||
|
downloads: z.number().int().nonnegative(),
|
||||||
|
colors: z.array(z.string()),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
featured: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WebGenerationAssetSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.enum(["Desktop", "Mobile", "Master"]),
|
||||||
|
ratio: AspectRatioSchema,
|
||||||
|
width: z.number().int().positive(),
|
||||||
|
height: z.number().int().positive(),
|
||||||
|
imageUrl: z.string().url()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WebGenerationGroupSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
prompt: z.string(),
|
||||||
|
negativePrompt: z.string().optional(),
|
||||||
|
status: GenerationGroupStatusSchema,
|
||||||
|
style: z.string(),
|
||||||
|
model: z.string(),
|
||||||
|
resolution: ResolutionTierSchema,
|
||||||
|
consistencyScore: z.number().int().min(0).max(100),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
assets: z.array(WebGenerationAssetSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WebUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
avatarInitials: z.string(),
|
||||||
|
theme: ThemePreferenceSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WebUserApiKeySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
provider: z.string(),
|
||||||
|
baseUrl: z.string(),
|
||||||
|
model: z.string(),
|
||||||
|
maskedKey: z.string(),
|
||||||
|
isDefault: z.boolean(),
|
||||||
|
status: z.enum(["untested", "connected", "failed"]),
|
||||||
|
updatedAt: z.string().datetime()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WebCreateGenerationInputSchema = z.object({
|
||||||
|
mode: z.enum(["text_to_image", "image_to_image"]),
|
||||||
|
prompt: z.string().min(1),
|
||||||
|
negativePrompt: z.string().optional(),
|
||||||
|
style: z.string(),
|
||||||
|
resolution: ResolutionTierSchema,
|
||||||
|
outputPair: z.boolean(),
|
||||||
|
provider: z.string(),
|
||||||
|
model: z.string(),
|
||||||
|
privateMode: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WebSaveApiKeyInputSchema = z.object({
|
||||||
|
provider: z.string(),
|
||||||
|
baseUrl: z.string().url(),
|
||||||
|
apiKey: z.string().min(1),
|
||||||
|
model: z.string(),
|
||||||
|
saveToAccount: z.boolean(),
|
||||||
|
isDefault: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ThemePreference = z.infer<typeof ThemePreferenceSchema>;
|
||||||
|
export type WebWallpaper = z.infer<typeof WebWallpaperSchema>;
|
||||||
|
export type WebGenerationAsset = z.infer<typeof WebGenerationAssetSchema>;
|
||||||
|
export type WebGenerationGroup = z.infer<typeof WebGenerationGroupSchema>;
|
||||||
|
export type WebUser = z.infer<typeof WebUserSchema>;
|
||||||
|
export type WebUserApiKey = z.infer<typeof WebUserApiKeySchema>;
|
||||||
|
export type WebCreateGenerationInput = z.infer<typeof WebCreateGenerationInputSchema>;
|
||||||
|
export type WebSaveApiKeyInput = z.infer<typeof WebSaveApiKeyInputSchema>;
|
||||||
7
packages/shared/src/index.ts
Executable file
7
packages/shared/src/index.ts
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./api-paths";
|
||||||
|
export * from "./model-capability";
|
||||||
|
export * from "./status";
|
||||||
|
export * from "./dto/app-config";
|
||||||
|
export * from "./dto/generation";
|
||||||
|
export * from "./dto/api-management";
|
||||||
|
export * from "./dto/web";
|
||||||
84
packages/shared/src/model-capability.ts
Executable file
84
packages/shared/src/model-capability.ts
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const GenerationModeSchema = z.enum(["text_to_image", "image_to_image"]);
|
||||||
|
export const GenerationQualitySchema = z.enum(["standard", "hd", "ultra"]);
|
||||||
|
export const ResolutionTierSchema = z.enum(["1k", "2k", "4k"]);
|
||||||
|
export const AspectRatioSchema = z.enum(["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]);
|
||||||
|
export const ProviderStatusSchema = z.enum(["disabled", "healthy", "degraded", "error"]);
|
||||||
|
export const ModelStatusSchema = z.enum(["draft", "enabled", "disabled", "deprecated"]);
|
||||||
|
export const ApiKeyModeSchema = z.enum(["platform", "user_own", "hybrid"]);
|
||||||
|
|
||||||
|
export type GenerationMode = z.infer<typeof GenerationModeSchema>;
|
||||||
|
export type GenerationQuality = z.infer<typeof GenerationQualitySchema>;
|
||||||
|
export type ResolutionTier = z.infer<typeof ResolutionTierSchema>;
|
||||||
|
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||||
|
export type ProviderStatus = z.infer<typeof ProviderStatusSchema>;
|
||||||
|
export type ModelStatus = z.infer<typeof ModelStatusSchema>;
|
||||||
|
export type ApiKeyMode = z.infer<typeof ApiKeyModeSchema>;
|
||||||
|
|
||||||
|
export const ImageSizePresetSchema = z.object({
|
||||||
|
aspectRatio: AspectRatioSchema,
|
||||||
|
resolution: ResolutionTierSchema,
|
||||||
|
width: z.number().int().positive(),
|
||||||
|
height: z.number().int().positive(),
|
||||||
|
providerSizeValue: z.string().optional(),
|
||||||
|
native: z.boolean().default(true),
|
||||||
|
requiresUpscale: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ModelPricingSchema = z.object({
|
||||||
|
currency: z.string().min(3).max(8).default("USD"),
|
||||||
|
unit: z.enum(["image", "megapixel", "request", "credit"]),
|
||||||
|
amount: z.number().nonnegative(),
|
||||||
|
estimatedCredits: z.number().nonnegative().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ModelCapabilitySchema = z.object({
|
||||||
|
supportsTextToImage: z.boolean(),
|
||||||
|
supportsImageToImage: z.boolean(),
|
||||||
|
supportsEdit: z.boolean().default(false),
|
||||||
|
supportsNegativePrompt: z.boolean().default(false),
|
||||||
|
supportsSeed: z.boolean().default(false),
|
||||||
|
supportsBatch: z.boolean().default(false),
|
||||||
|
supportsStreaming: z.boolean().default(false),
|
||||||
|
supportsBase64Result: z.boolean().default(false),
|
||||||
|
supportsUrlResult: z.boolean().default(true),
|
||||||
|
supportsNative4k: z.boolean().default(false),
|
||||||
|
maxBatchSize: z.number().int().positive().default(1),
|
||||||
|
maxInputImages: z.number().int().nonnegative().default(0),
|
||||||
|
maxPromptLength: z.number().int().positive().default(4000),
|
||||||
|
maxNegativePromptLength: z.number().int().positive().default(2000),
|
||||||
|
maxPixels: z.number().int().positive().optional(),
|
||||||
|
supportedAspectRatios: z.array(AspectRatioSchema).min(1),
|
||||||
|
supportedResolutions: z.array(ResolutionTierSchema).min(1),
|
||||||
|
sizePresets: z.array(ImageSizePresetSchema).min(1),
|
||||||
|
defaultParams: z.record(z.unknown()).default({})
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ImageSizePreset = z.infer<typeof ImageSizePresetSchema>;
|
||||||
|
export type ModelPricing = z.infer<typeof ModelPricingSchema>;
|
||||||
|
export type ModelCapability = z.infer<typeof ModelCapabilitySchema>;
|
||||||
|
|
||||||
|
export const ModelSummarySchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
providerId: z.string().uuid(),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
status: ModelStatusSchema,
|
||||||
|
keyMode: ApiKeyModeSchema,
|
||||||
|
capability: ModelCapabilitySchema,
|
||||||
|
pricing: ModelPricingSchema.optional(),
|
||||||
|
sortOrder: z.number().int().default(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProviderSummarySchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
status: ProviderStatusSchema,
|
||||||
|
keyMode: ApiKeyModeSchema,
|
||||||
|
modelCount: z.number().int().nonnegative().default(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ModelSummary = z.infer<typeof ModelSummarySchema>;
|
||||||
|
export type ProviderSummary = z.infer<typeof ProviderSummarySchema>;
|
||||||
59
packages/shared/src/status.ts
Executable file
59
packages/shared/src/status.ts
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const generationGroupStatuses = [
|
||||||
|
"queued",
|
||||||
|
"running",
|
||||||
|
"partial_succeeded",
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
"canceled"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const generationTaskStatuses = [
|
||||||
|
"created",
|
||||||
|
"queued",
|
||||||
|
"dispatching",
|
||||||
|
"running",
|
||||||
|
"uploading",
|
||||||
|
"post_processing",
|
||||||
|
"moderating",
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
"retrying",
|
||||||
|
"canceled",
|
||||||
|
"expired"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const terminalGenerationTaskStatuses = [
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
"canceled",
|
||||||
|
"expired"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const retryableGenerationTaskStatuses = ["failed", "retrying"] as const;
|
||||||
|
|
||||||
|
export const GenerationGroupStatusSchema = z.enum(generationGroupStatuses);
|
||||||
|
export const GenerationTaskStatusSchema = z.enum(generationTaskStatuses);
|
||||||
|
|
||||||
|
export type GenerationGroupStatus = z.infer<typeof GenerationGroupStatusSchema>;
|
||||||
|
export type GenerationTaskStatus = z.infer<typeof GenerationTaskStatusSchema>;
|
||||||
|
|
||||||
|
export const generationTaskStateTransitions: Record<GenerationTaskStatus, readonly GenerationTaskStatus[]> = {
|
||||||
|
created: ["queued", "canceled"],
|
||||||
|
queued: ["dispatching", "canceled", "expired"],
|
||||||
|
dispatching: ["running", "retrying", "failed", "canceled"],
|
||||||
|
running: ["uploading", "retrying", "failed", "canceled"],
|
||||||
|
uploading: ["post_processing", "retrying", "failed", "canceled"],
|
||||||
|
post_processing: ["moderating", "retrying", "failed", "canceled"],
|
||||||
|
moderating: ["succeeded", "failed", "canceled"],
|
||||||
|
succeeded: [],
|
||||||
|
failed: ["retrying"],
|
||||||
|
retrying: ["queued", "failed", "canceled", "expired"],
|
||||||
|
canceled: [],
|
||||||
|
expired: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isTerminalGenerationTaskStatus(status: GenerationTaskStatus): boolean {
|
||||||
|
return terminalGenerationTaskStatuses.includes(status as (typeof terminalGenerationTaskStatuses)[number]);
|
||||||
|
}
|
||||||
9
packages/shared/tsconfig.json
Executable file
9
packages/shared/tsconfig.json
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
14
packages/ui-tokens/package.json
Executable file
14
packages/ui-tokens/package.json
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@wallmuse/ui-tokens",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/ui-tokens/src/index.ts
Executable file
7
packages/ui-tokens/src/index.ts
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
export const breakpoints = {
|
||||||
|
xs: 480,
|
||||||
|
sm: 640,
|
||||||
|
md: 900,
|
||||||
|
lg: 1200,
|
||||||
|
xl: 1600
|
||||||
|
} as const;
|
||||||
70
packages/ui-tokens/src/tokens.css
Executable file
70
packages/ui-tokens/src/tokens.css
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-bg-page: #edf6ff;
|
||||||
|
--color-bg-surface: rgba(255, 255, 255, 0.62);
|
||||||
|
--color-bg-surface-strong: rgba(255, 255, 255, 0.82);
|
||||||
|
--color-bg-control: rgba(255, 255, 255, 0.56);
|
||||||
|
--color-bg-control-active: rgba(230, 240, 255, 0.86);
|
||||||
|
--color-border-soft: rgba(120, 150, 190, 0.2);
|
||||||
|
--color-border-strong: rgba(120, 160, 220, 0.36);
|
||||||
|
--color-text-primary: #172235;
|
||||||
|
--color-text-secondary: #617087;
|
||||||
|
--color-text-muted: #97a4b7;
|
||||||
|
--color-accent: #2f86ff;
|
||||||
|
--color-accent-soft: #dceaff;
|
||||||
|
--color-success: #20c997;
|
||||||
|
--color-warning: #f7b955;
|
||||||
|
--color-danger: #ff5c7a;
|
||||||
|
--font-xs: 12px;
|
||||||
|
--font-sm: 13px;
|
||||||
|
--font-md: 14px;
|
||||||
|
--font-lg: 16px;
|
||||||
|
--font-xl: 20px;
|
||||||
|
--font-2xl: 28px;
|
||||||
|
--font-3xl: 40px;
|
||||||
|
--radius-xs: 8px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-lg: 24px;
|
||||||
|
--radius-xl: 36px;
|
||||||
|
--radius-full: 999px;
|
||||||
|
--shadow-shell: 0 28px 80px rgba(80, 130, 190, 0.18);
|
||||||
|
--shadow-card: 0 14px 34px rgba(37, 60, 90, 0.16);
|
||||||
|
--shadow-floating: 0 18px 42px rgba(40, 80, 130, 0.22);
|
||||||
|
--glass-blur: blur(22px) saturate(140%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-bg-page: #07111f;
|
||||||
|
--color-bg-surface: rgba(15, 27, 45, 0.68);
|
||||||
|
--color-bg-surface-strong: rgba(18, 31, 52, 0.88);
|
||||||
|
--color-bg-control: rgba(23, 38, 62, 0.72);
|
||||||
|
--color-bg-control-active: rgba(35, 78, 135, 0.76);
|
||||||
|
--color-border-soft: rgba(170, 205, 255, 0.14);
|
||||||
|
--color-border-strong: rgba(95, 165, 255, 0.52);
|
||||||
|
--color-text-primary: #eef6ff;
|
||||||
|
--color-text-secondary: #a7b6ca;
|
||||||
|
--color-text-muted: #687a92;
|
||||||
|
--color-accent: #66aaff;
|
||||||
|
--color-accent-soft: rgba(102, 170, 255, 0.18);
|
||||||
|
--color-success: #35d8a6;
|
||||||
|
--color-warning: #ffd166;
|
||||||
|
--color-danger: #ff6b8b;
|
||||||
|
--shadow-shell: 0 28px 80px rgba(0, 0, 0, 0.42);
|
||||||
|
--shadow-card: 0 16px 38px rgba(0, 0, 0, 0.34);
|
||||||
|
--shadow-floating: 0 20px 48px rgba(0, 0, 0, 0.46);
|
||||||
|
--glass-blur: blur(24px) saturate(130%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
|
:root {
|
||||||
|
--color-bg-surface: rgba(255, 255, 255, 0.92);
|
||||||
|
--color-bg-surface-strong: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-bg-surface: rgba(15, 27, 45, 0.92);
|
||||||
|
--color-bg-surface-strong: rgba(18, 31, 52, 0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/ui-tokens/tsconfig.json
Executable file
13
packages/ui-tokens/tsconfig.json
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
5524
pnpm-lock.yaml
generated
Executable file
5524
pnpm-lock.yaml
generated
Executable file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Executable file
3
pnpm-workspace.yaml
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
|
- "apps/*"
|
||||||
30
scripts/health-check.sh
Executable file
30
scripts/health-check.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
echo "WallMuse health check"
|
||||||
|
echo "Workspace: $ROOT_DIR"
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
docker compose -f infra/docker-compose.yml --env-file .env ps
|
||||||
|
else
|
||||||
|
echo "docker is not installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_tcp() {
|
||||||
|
local name="$1"
|
||||||
|
local host="$2"
|
||||||
|
local port="$3"
|
||||||
|
if timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||||
|
echo "ok: $name $host:$port"
|
||||||
|
else
|
||||||
|
echo "warn: $name $host:$port is not reachable"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_tcp postgres 127.0.0.1 5432
|
||||||
|
check_tcp redis 127.0.0.1 6379
|
||||||
|
check_tcp minio 127.0.0.1 9000
|
||||||
|
check_tcp minio-console 127.0.0.1 9001
|
||||||
19
scripts/run-mock-generation.ts
Normal file
19
scripts/run-mock-generation.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { JsonWallMuseDb } from "../packages/db/src/json-store.js";
|
||||||
|
import type { GenerationWorkerJobData } from "../packages/db/src/json-store.js";
|
||||||
|
import { GenerationProcessor } from "../apps/worker-generation/src/processor.js";
|
||||||
|
const db = JsonWallMuseDb.fromEnv();
|
||||||
|
const processor = new GenerationProcessor(db);
|
||||||
|
const jobData: GenerationWorkerJobData = { groupId: crypto.randomUUID(), taskId: crypto.randomUUID(), userId: "user_mock", mode: "text_to_image", prompt: "futuristic city at sunrise, clean wallpaper composition", negativePrompt: "text, logo, watermark", resolution: "2k", providerId: "00000000-0000-4000-8000-000000000001", providerSlug: "mock", modelId: "00000000-0000-4000-8000-000000000101", modelSlug: "mock-wallpaper-v1", seed: 42 };
|
||||||
|
await db.seedQueuedGeneration(jobData);
|
||||||
|
console.log(`queued group=${jobData.groupId} task=${jobData.taskId}`);
|
||||||
|
await processor.process({ data: jobData, updateProgress: async (progress: number) => console.log(`progress=${progress}`) } as never);
|
||||||
|
const state = await db.read();
|
||||||
|
const group = state.generationGroups.find((item) => item.id === jobData.groupId);
|
||||||
|
const assets = group?.assets ?? [];
|
||||||
|
const logs = state.providerCallLogs.filter((item) => item.groupId === jobData.groupId);
|
||||||
|
console.log(`final status=${group?.status}`);
|
||||||
|
console.log(`assets=${assets.map((asset) => `${asset.kind}:${asset.status}`).join(",")}`);
|
||||||
|
console.log(`provider_call_logs=${logs.length}`);
|
||||||
|
if (group?.status !== "succeeded") throw new Error(`Expected succeeded, received ${group?.status ?? "missing"}`);
|
||||||
|
if (!["master", "landscape", "portrait"].every((kind) => assets.some((asset) => asset.kind === kind && asset.status === "active"))) throw new Error("Expected active master, landscape and portrait assets");
|
||||||
|
if (logs.length < 3 || !logs.every((log) => log.status === "succeeded")) throw new Error("Expected succeeded provider_call_logs for each generated asset");
|
||||||
43
tsconfig.base.json
Executable file
43
tsconfig.base.json
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@wallmuse/shared": [
|
||||||
|
"packages/shared/src/index.ts"
|
||||||
|
],
|
||||||
|
"@wallmuse/shared/*": [
|
||||||
|
"packages/shared/src/*"
|
||||||
|
],
|
||||||
|
"@wallmuse/db": [
|
||||||
|
"packages/db/src/index.ts"
|
||||||
|
],
|
||||||
|
"@wallmuse/api-client": [
|
||||||
|
"packages/api-client/src/index.ts"
|
||||||
|
],
|
||||||
|
"@wallmuse/ui-tokens": [
|
||||||
|
"packages/ui-tokens/src/index.ts"
|
||||||
|
],
|
||||||
|
"@wallmuse/ui-tokens/*": [
|
||||||
|
"packages/ui-tokens/src/*"
|
||||||
|
],
|
||||||
|
"@wallmuse/provider-adapters": [
|
||||||
|
"packages/provider-adapters/src/index.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tsconfig.json
Executable file
15
tsconfig.json
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./packages/shared" },
|
||||||
|
{ "path": "./packages/api-client" },
|
||||||
|
{ "path": "./packages/db" },
|
||||||
|
{ "path": "./packages/ui-tokens" },
|
||||||
|
{ "path": "./packages/provider-adapters" },
|
||||||
|
{ "path": "./packages/image-pipeline" },
|
||||||
|
{ "path": "./apps/api" },
|
||||||
|
{ "path": "./apps/web" },
|
||||||
|
{ "path": "./apps/worker-image" },
|
||||||
|
{ "path": "./apps/worker-moderation" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user