Initial WallMuse project

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

12
apps/admin/index.html Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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