From 3ea7d298277233c35689ba3ce7e005af337127d4 Mon Sep 17 00:00:00 2001 From: fenglee Date: Sat, 9 May 2026 09:12:41 +0000 Subject: [PATCH] Initial WallMuse project --- .env.example | 5 + .gitignore | 41 + apps/admin/index.html | 12 + apps/admin/package.json | 25 + apps/admin/src/main.tsx | 727 +++ apps/admin/src/styles.css | 803 +++ apps/admin/tsconfig.json | 12 + apps/admin/vite.config.ts | 15 + apps/api/package.json | 25 + apps/api/src/auth.ts | 51 + apps/api/src/errors.ts | 22 + apps/api/src/routes.ts | 380 ++ apps/api/src/security.ts | 42 + apps/api/src/server.ts | 53 + apps/api/src/smoke-test.ts | 94 + apps/api/tsconfig.json | 14 + apps/web/next-env.d.ts | 5 + apps/web/next.config.mjs | 9 + apps/web/package.json | 27 + apps/web/src/app/generate/page.tsx | 180 + apps/web/src/app/globals.css | 801 +++ apps/web/src/app/layout.tsx | 40 + apps/web/src/app/login/page.tsx | 54 + apps/web/src/app/page.tsx | 86 + apps/web/src/app/register/page.tsx | 48 + apps/web/src/app/results/[id]/page.tsx | 69 + apps/web/src/app/settings/api-keys/page.tsx | 108 + apps/web/src/components/app-shell.tsx | 86 + apps/web/src/components/category-rail.tsx | 28 + apps/web/src/components/wallpaper-card.tsx | 31 + apps/web/src/components/wallpaper-grid.tsx | 12 + apps/web/tsconfig.json | 24 + apps/worker-generation/package.json | 8 + apps/worker-generation/src/connection.ts | 20 + apps/worker-generation/src/index.ts | 8 + apps/worker-generation/src/processor.ts | 58 + apps/worker-generation/src/state-machine.ts | 18 + apps/worker-generation/src/worker.ts | 10 + apps/worker-generation/tsconfig.json | 5 + docs/WALLMUSE_INFRA_STATUS.md | 25 + infra/docker-compose.yml | 78 + infra/minio/create-buckets.sh | 16 + infra/nginx/wallmuse.dev.conf | 28 + infra/postgres/README.md | 9 + package.json | 51 + packages/api-client/package.json | 22 + packages/api-client/src/index.ts | 390 ++ packages/api-client/tsconfig.json | 10 + packages/db/package.json | 20 + packages/db/prisma/schema.prisma | 457 ++ packages/db/src/index.ts | 1 + packages/db/src/json-store.ts | 523 ++ packages/db/tsconfig.json | 13 + packages/image-pipeline/package.json | 11 + packages/image-pipeline/src/index.ts | 7 + packages/image-pipeline/tsconfig.json | 1 + packages/provider-adapters/package.json | 25 + .../src/__tests__/provider-adapters.test.ts | 178 + .../src/adapters/base-http-adapter.ts | 165 + .../provider-adapters/src/asset-normalizer.ts | 104 + .../src/capabilities/presets.ts | 183 + packages/provider-adapters/src/errors.ts | 137 + packages/provider-adapters/src/index.ts | 16 + .../provider-adapters/src/providers/mock.ts | 70 + .../src/providers/openai-compatible.ts | 23 + .../src/providers/placeholders.ts | 50 + .../src/providers/siliconflow.ts | 29 + packages/provider-adapters/src/registry.ts | 33 + packages/provider-adapters/src/types.ts | 144 + packages/provider-adapters/src/url.ts | 14 + packages/provider-adapters/tsconfig.json | 10 + packages/shared/package.json | 21 + packages/shared/src/api-paths.ts | 9 + packages/shared/src/dto/api-management.ts | 101 + packages/shared/src/dto/app-config.ts | 33 + packages/shared/src/dto/generation.ts | 84 + packages/shared/src/dto/web.ts | 92 + packages/shared/src/index.ts | 7 + packages/shared/src/model-capability.ts | 84 + packages/shared/src/status.ts | 59 + packages/shared/tsconfig.json | 9 + packages/ui-tokens/package.json | 14 + packages/ui-tokens/src/index.ts | 7 + packages/ui-tokens/src/tokens.css | 70 + packages/ui-tokens/tsconfig.json | 13 + pnpm-lock.yaml | 5524 +++++++++++++++++ pnpm-workspace.yaml | 3 + scripts/health-check.sh | 30 + scripts/run-mock-generation.ts | 19 + tsconfig.base.json | 43 + tsconfig.json | 15 + 91 files changed, 13136 insertions(+) create mode 100755 .env.example create mode 100644 .gitignore create mode 100755 apps/admin/index.html create mode 100755 apps/admin/package.json create mode 100755 apps/admin/src/main.tsx create mode 100755 apps/admin/src/styles.css create mode 100755 apps/admin/tsconfig.json create mode 100755 apps/admin/vite.config.ts create mode 100755 apps/api/package.json create mode 100755 apps/api/src/auth.ts create mode 100755 apps/api/src/errors.ts create mode 100755 apps/api/src/routes.ts create mode 100755 apps/api/src/security.ts create mode 100755 apps/api/src/server.ts create mode 100755 apps/api/src/smoke-test.ts create mode 100755 apps/api/tsconfig.json create mode 100755 apps/web/next-env.d.ts create mode 100755 apps/web/next.config.mjs create mode 100755 apps/web/package.json create mode 100755 apps/web/src/app/generate/page.tsx create mode 100755 apps/web/src/app/globals.css create mode 100755 apps/web/src/app/layout.tsx create mode 100755 apps/web/src/app/login/page.tsx create mode 100755 apps/web/src/app/page.tsx create mode 100755 apps/web/src/app/register/page.tsx create mode 100755 apps/web/src/app/results/[id]/page.tsx create mode 100755 apps/web/src/app/settings/api-keys/page.tsx create mode 100755 apps/web/src/components/app-shell.tsx create mode 100755 apps/web/src/components/category-rail.tsx create mode 100755 apps/web/src/components/wallpaper-card.tsx create mode 100755 apps/web/src/components/wallpaper-grid.tsx create mode 100755 apps/web/tsconfig.json create mode 100644 apps/worker-generation/package.json create mode 100644 apps/worker-generation/src/connection.ts create mode 100644 apps/worker-generation/src/index.ts create mode 100644 apps/worker-generation/src/processor.ts create mode 100644 apps/worker-generation/src/state-machine.ts create mode 100644 apps/worker-generation/src/worker.ts create mode 100644 apps/worker-generation/tsconfig.json create mode 100755 docs/WALLMUSE_INFRA_STATUS.md create mode 100755 infra/docker-compose.yml create mode 100755 infra/minio/create-buckets.sh create mode 100755 infra/nginx/wallmuse.dev.conf create mode 100755 infra/postgres/README.md create mode 100755 package.json create mode 100755 packages/api-client/package.json create mode 100755 packages/api-client/src/index.ts create mode 100755 packages/api-client/tsconfig.json create mode 100755 packages/db/package.json create mode 100644 packages/db/prisma/schema.prisma create mode 100755 packages/db/src/index.ts create mode 100755 packages/db/src/json-store.ts create mode 100755 packages/db/tsconfig.json create mode 100644 packages/image-pipeline/package.json create mode 100644 packages/image-pipeline/src/index.ts create mode 100644 packages/image-pipeline/tsconfig.json create mode 100644 packages/provider-adapters/package.json create mode 100644 packages/provider-adapters/src/__tests__/provider-adapters.test.ts create mode 100644 packages/provider-adapters/src/adapters/base-http-adapter.ts create mode 100644 packages/provider-adapters/src/asset-normalizer.ts create mode 100644 packages/provider-adapters/src/capabilities/presets.ts create mode 100644 packages/provider-adapters/src/errors.ts create mode 100644 packages/provider-adapters/src/index.ts create mode 100644 packages/provider-adapters/src/providers/mock.ts create mode 100644 packages/provider-adapters/src/providers/openai-compatible.ts create mode 100644 packages/provider-adapters/src/providers/placeholders.ts create mode 100644 packages/provider-adapters/src/providers/siliconflow.ts create mode 100644 packages/provider-adapters/src/registry.ts create mode 100644 packages/provider-adapters/src/types.ts create mode 100644 packages/provider-adapters/src/url.ts create mode 100644 packages/provider-adapters/tsconfig.json create mode 100755 packages/shared/package.json create mode 100755 packages/shared/src/api-paths.ts create mode 100755 packages/shared/src/dto/api-management.ts create mode 100755 packages/shared/src/dto/app-config.ts create mode 100755 packages/shared/src/dto/generation.ts create mode 100755 packages/shared/src/dto/web.ts create mode 100755 packages/shared/src/index.ts create mode 100755 packages/shared/src/model-capability.ts create mode 100755 packages/shared/src/status.ts create mode 100755 packages/shared/tsconfig.json create mode 100755 packages/ui-tokens/package.json create mode 100755 packages/ui-tokens/src/index.ts create mode 100755 packages/ui-tokens/src/tokens.css create mode 100755 packages/ui-tokens/tsconfig.json create mode 100755 pnpm-lock.yaml create mode 100755 pnpm-workspace.yaml create mode 100755 scripts/health-check.sh create mode 100644 scripts/run-mock-generation.ts create mode 100755 tsconfig.base.json create mode 100755 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..94c01a7 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +API_HOST=0.0.0.0 +API_PORT=4000 +JWT_SECRET=change-me-in-production +API_KEY_ENCRYPTION_SECRET=replace-with-32-byte-secret +WALLMUSE_DATA_DIR=/root/wallmuse/app/.data diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..936ab81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Environment and local secrets +.env +.env.* +!.env.example + +# Runtime data +.data/ +.wallmuse-data/ +reports/ +*.log + +# Build output +dist/ +build/ +.next/ +coverage/ +playwright-report/ +test-results/ + +# TypeScript incremental build info +*.tsbuildinfo + +# OS/editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# Temporary files +*.tmp +*.temp + +# Generated TypeScript output in source folders +packages/**/src/**/*.js +packages/**/src/**/*.d.ts +apps/**/src/**/*.js +apps/**/src/**/*.d.ts diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100755 index 0000000..0d6e193 --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,12 @@ + + + + + + WallMuse Admin + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100755 index 0000000..20086ae --- /dev/null +++ b/apps/admin/package.json @@ -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" + } +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100755 index 0000000..7352a3a --- /dev/null +++ b/apps/admin/src/main.tsx @@ -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(null); + const [view, setView] = useState("dashboard"); + const [theme, setTheme] = useState(() => (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 ; + } + + if (!token || !currentUser) { + return ; + } + + return ( + 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 ( +
+
+
+ +
+

WallMuse Admin

+

运营后台

+
+
+
+ + + {error ?

{error}

: null} + +
+
+
+ ); +} + +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 ( +
+
+ +
+
+
+

管理后台

+

{active.label}

+
+
+ + + +
+ {props.currentUser.name.slice(0, 1).toUpperCase()} +
+ {props.currentUser.name} + {props.currentUser.roles.join(" / ")} +
+ +
+ +
+
+
{renderView(props.view, props.client)}
+
+
+
+ ); +} + +function renderView(view: ViewKey, client: WallMuseApiClient) { + if (view === "dashboard") return ; + if (view === "users") return ; + if (view === "providers") return ; + if (view === "models") return ; + if (view === "tasks") return ; + if (view === "gallery") return ; + return ; +} + +function DashboardView({ client }: { client: WallMuseApiClient }) { + const { data, loading, reload } = useApiData(() => client.getDashboard(), [client]); + + if (loading || !data) return ; + + return ( +
+ +
+ + + + + + +
+
+ + +
+
+ + +
+
+ ); +} + +function UsersView({ client }: { client: WallMuseApiClient }) { + const { data } = useApiData(() => client.getUsers(), [client]); + return ( + + + + + + + + + + + + + + + {(data ?? []).map((user) => ( + + + + + + + + + + ))} + +
用户状态套餐积分API Key生成数最近活跃
+ {user.name} + {user.email} + {user.plan}{user.credits.toLocaleString()}{user.apiKeyCount}{user.generationCount}{formatDate(user.lastActiveAt)}
+
+ ); +} + +function ProvidersView({ client }: { client: WallMuseApiClient }) { + const { data } = useApiData(() => client.getProviders(), [client]); + return ( + + + + + + + + + + + + + + + + {(data ?? []).map((provider) => ( + + + + + + + + + + + ))} + +
供应商状态Base URLKey 模式模型日限额成功率延迟
+ {provider.name} + {provider.code} + {provider.baseUrl}{provider.keyMode}{provider.modelCount}{provider.dailyLimit.toLocaleString()}{provider.successRate}%{provider.avgLatencyMs}ms
+
+ ); +} + +function ModelsView({ client }: { client: WallMuseApiClient }) { + const { data } = useApiData(() => client.getModels(), [client]); + return ( + +
+ {(data ?? []).map((model) => ( +
+
+
+ {model.name} + {model.modelId} +
+ +
+
{model.modes.map((item) => {item})}
+
+
分辨率
{model.resolutions.join(" / ")}
+
比例
{model.ratios.join(" / ")}
+
原生 4K
{model.native4k ? "支持" : "超分"}
+
价格
{model.priceCredits} credits
+
批量
{model.maxBatchSize}
+
+
+ ))} +
+
+ ); +} + +function TasksView({ client }: { client: WallMuseApiClient }) { + const { data, reload } = useApiData(() => client.getTasks(), [client]); + return ( + + + + ); +} + +function GalleryView({ client }: { client: WallMuseApiClient }) { + const { data } = useApiData(() => client.getGalleryReviewItems(), [client]); + return ( + +
+ {(data ?? []).map((item) => ( +
+ {item.title} +
+
+
+ {item.title} + {item.authorEmail} +
+ +
+

{item.prompt}

+
+ {item.ratio} + {item.resolution} + risk: {item.riskLevel} + {item.tags.map((tag) => {tag})} +
+
+ + + +
+
+
+ ))} +
+
+ ); +} + +function SettingsView({ client }: { client: WallMuseApiClient }) { + const { data } = useApiData(() => client.getSystemSettings(), [client]); + const settings = data as SystemSettings | undefined; + return ( + + {settings ? ( +
+ + + + + + + + + +
+ ) : ( + + )} +
+ ); +} + +function TaskTable({ tasks, compact = false }: { tasks: AdminTask[]; compact?: boolean }) { + return ( + + + + + + + + + + + + + + + {tasks.map((task) => ( + + + + + + + + + + + ))} + +
任务状态用户输出分辨率进度成本更新时间
+ {task.id} + {task.prompt} + {task.userEmail}{task.outputs.join(" + ")}{task.resolutionTier}{task.costCredits}{formatDate(task.updatedAt)}
+ ); +} + +function TrendPanel({ data }: { data: AdminDashboard }) { + const max = Math.max(...data.generationTrend.map((item) => item.value)); + return ( +
+
+
+

七日生成趋势

+

按任务组统计,失败量叠加显示

+
+ +
+
+ {data.generationTrend.map((item) => ( +
+
+ + +
+ {item.label} +
+ ))} +
+
+ ); +} + +function QueuePanel({ data }: { data: AdminDashboard }) { + return ( +
+
+
+

队列状态

+

Worker 消费与失败积压

+
+ +
+
+
等待
{data.queue.waiting}
+
运行
{data.queue.running}
+
失败
{data.queue.failed}
+
P95 延迟
{data.queue.p95LatencyMs}ms
+
+
+ ); +} + +function ProviderHealth({ providers }: { providers: AdminProvider[] }) { + return ( +
+
+
+

供应商健康

+

成功率、延迟和限额

+
+ +
+
+ {providers.map((provider) => ( +
+
+ {provider.name} + {provider.avgLatencyMs}ms avg +
+ + +
+ ))} +
+
+ ); +} + +function DataPanel(props: { + title: string; + description: string; + children: React.ReactNode; + onRefresh?: (() => void) | undefined; +}) { + return ( +
+ + {props.children} +
+ ); +} + +function Toolbar({ + title, + description, + onRefresh +}: { + title: string; + description: string; + onRefresh?: (() => void) | undefined; +}) { + return ( +
+
+

{title}

+

{description}

+
+
+ + +
+
+ ); +} + +function KpiCard({ label, value, delta, tone }: { label: string; value: string; delta: string; tone: string }) { + return ( +
+ {label} + {value} + {delta} +
+ ); +} + +function StatusPill({ status }: { status: string }) { + return {status}; +} + +function Progress({ value }: { value: number }) { + return ( +
+ + {Math.round(value)}% +
+ ); +} + +function SettingsField({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) { + return ( + + ); +} + +function ToggleRow({ label, active }: { label: string; active: boolean }) { + return ( +
+ {label} + +
+ ); +} + +function LogoMark() { + return ( + + ); +} + +function SectionSkeleton({ title }: { title: string }) { + return ( +
+ + {title} +
+ ); +} + +function FullPageStatus({ label }: { label: string }) { + return ( +
+
+ + {label} +
+
+ ); +} + +function useApiData(loader: () => Promise, deps: React.DependencyList) { + const [data, setData] = useState(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( + + + +); diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css new file mode 100755 index 0000000..0c024fc --- /dev/null +++ b/apps/admin/src/styles.css @@ -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; + } +} diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100755 index 0000000..0f99354 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -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" }] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100755 index 0000000..24c8bcf --- /dev/null +++ b/apps/admin/vite.config.ts @@ -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 + } + } + } +}); diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100755 index 0000000..656376a --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100755 index 0000000..9e16884 --- /dev/null +++ b/apps/api/src/auth.ts @@ -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 => { + const token = extractToken(request); + if (!token) { + throw new ApiError(401, "UNAUTHORIZED", "Authentication is required"); + } + const payload = await request.server.jwt.verify(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 => { + 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 => + app.jwt.sign({ sub: user.id, roles: user.roles } satisfies JwtPayload, { expiresIn: "7d" }); diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts new file mode 100755 index 0000000..d590cde --- /dev/null +++ b/apps/api/src/errors.ts @@ -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 + } + }); +}; diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts new file mode 100755 index 0000000..1c2ffcd --- /dev/null +++ b/apps/api/src/routes.ts @@ -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 = (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 +) => { + 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 = (apiKey: T): Omit => { + 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 => { + 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; + }); +}; diff --git a/apps/api/src/security.ts b/apps/api/src/security.ts new file mode 100755 index 0000000..1c0bd4b --- /dev/null +++ b/apps/api/src/security.ts @@ -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 => bcrypt.hash(password, 12); + +export const verifyPassword = (password: string, hash: string): Promise => + 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)}`; +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100755 index 0000000..2308491 --- /dev/null +++ b/apps/api/src/server.ts @@ -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 }); +} diff --git a/apps/api/src/smoke-test.ts b/apps/api/src/smoke-test.ts new file mode 100755 index 0000000..aa8da86 --- /dev/null +++ b/apps/api/src/smoke-test.ts @@ -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(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100755 index 0000000..d9eb1ba --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100755 index 0000000..1b3be08 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100755 index 0000000..88e517d --- /dev/null +++ b/apps/web/next.config.mjs @@ -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; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100755 index 0000000..4e8a9e1 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/app/generate/page.tsx b/apps/web/src/app/generate/page.tsx new file mode 100755 index 0000000..37caf19 --- /dev/null +++ b/apps/web/src/app/generate/page.tsx @@ -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 ( + +
+
+

+ + Generation workspace +

+

一次生成桌面和手机两种壁纸。

+

参数面板、双规格预览和任务队列在桌面端并排展示,移动端自动收拢为单列。

+
+ +
+
+
+ + +
+ +
+ +