diff --git a/.memory/worklog.json b/.memory/worklog.json index 91b4f85..76173dc 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -690,6 +690,26 @@ "message": "auto-save 2026-05-19 13:40 (+1, ~2)", "hash": "f4ce3d4", "files_changed": 3 + }, + { + "ts": "2026-05-19T13:45:51+08:00", + "type": "commit", + "message": "chore: align local docker environment", + "hash": "c49e1b3", + "files_changed": 6 + }, + { + "ts": "2026-05-19T05:50:02Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:chore: align local docker environment", + "files_changed": 1 + }, + { + "ts": "2026-05-19T13:56:44+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 13:56 (+1, ~1)", + "hash": "cdda350", + "files_changed": 2 } ] } diff --git a/src/app/api/gallery/[sessionId]/route.ts b/src/app/api/gallery/[sessionId]/route.ts new file mode 100644 index 0000000..05cfb4a --- /dev/null +++ b/src/app/api/gallery/[sessionId]/route.ts @@ -0,0 +1,185 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { NextResponse } from 'next/server'; +import { loadSession } from '@/lib/storage'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type GalleryItem = { + filename: string; + url: string; + group: string; + label: string; + sizeKb: number; + mtime: number; + current: boolean; +}; + +function escapeHtml(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function packGroup(filename: string): string { + const match = filename.match(/^pack_([^_]+)_/); + return match?.[1] ?? 'unknown'; +} + +function fileLabel(filename: string): string { + return filename + .replace(/^pack_[^_]+_[^_]+_[^_]+_/, '') + .replace(/\.[a-z0-9]+$/i, '') + .replace(/_/g, ' '); +} + +async function listPackItems(currentFilenames: Set): Promise { + const dir = path.join(process.cwd(), 'data', 'packs'); + let files: string[] = []; + try { + files = await fs.readdir(dir); + } catch { + return []; + } + + const items = await Promise.all( + files + .filter(filename => /\.(png|jpe?g|webp)$/i.test(filename)) + .map(async filename => { + const stat = await fs.stat(path.join(dir, filename)); + return { + filename, + url: `/api/img/packs/${encodeURIComponent(filename)}`, + group: packGroup(filename), + label: fileLabel(filename), + sizeKb: Math.round(stat.size / 1024), + mtime: stat.mtimeMs, + current: currentFilenames.has(filename), + } satisfies GalleryItem; + }), + ); + + return items.sort((a, b) => { + if (a.current !== b.current) return a.current ? -1 : 1; + if (a.group !== b.group) return a.group.localeCompare(b.group); + return a.mtime - b.mtime; + }); +} + +function renderItems(title: string, items: GalleryItem[]): string { + const cards = items.map(item => ` +
+ + ${escapeHtml(item.filename)} + +
+ ${escapeHtml(item.group)} · ${escapeHtml(item.label)} + ${escapeHtml(item.filename)} + ${item.sizeKb} KB +
+
+ `).join(''); + + return ` +
+

${escapeHtml(title)} ${items.length}

+
${cards || '

No images.

'}
+
+ `; +} + +function renderPage(opts: { + sessionId: string; + current: GalleryItem[]; + archived: GalleryItem[]; + packsSummary: string; + sourceHtml: string; +}): string { + const total = opts.current.length + opts.archived.length; + return ` + + + + + ${escapeHtml(opts.sessionId)} Gallery + + + +
+
+

MUSE MATE 生成图库

+
Session: ${escapeHtml(opts.sessionId)}
+ ${opts.sourceHtml} +
+
+ 总图 ${total} + 当前有效 ${opts.current.length} + 历史未挂载 ${opts.archived.length} + ${opts.packsSummary} +
+
+ ${renderItems('当前 session 有效图', opts.current)} + ${renderItems('历史生成图 / 未挂载但已保存', opts.archived)} + +`; +} + +export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: string }> }) { + const { sessionId } = await ctx.params; + if (!/^s_[a-z0-9_-]+$/i.test(sessionId)) { + return NextResponse.json({ error: 'bad sessionId' }, { status: 400 }); + } + + const session = await loadSession(sessionId); + if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + + const currentFilenames = new Set( + (session.packs ?? []) + .flatMap(pack => pack.assets ?? []) + .map(asset => asset.url?.match(/\/api\/img\/packs\/([^/?#]+)$/)?.[1]) + .filter((filename): filename is string => Boolean(filename)) + .map(filename => decodeURIComponent(filename)), + ); + const allItems = await listPackItems(currentFilenames); + const sourceLinks = [ + session.characterSpec?.cleanReferenceImageUrl ? `L1 白底锚图` : '', + ...(session.uploadedImages ?? []).map(upload => `上传图 ${escapeHtml(upload.originalFilename ?? upload.filename)}`), + ].filter(Boolean).join(''); + const packsSummary = (session.packs ?? []).map(pack => ( + `${escapeHtml(pack.kind)} ${pack.status} ${pack.assets.length}` + )).join(''); + + return new NextResponse(renderPage({ + sessionId, + current: allItems.filter(item => item.current), + archived: allItems.filter(item => !item.current), + packsSummary, + sourceHtml: sourceLinks ? `
${sourceLinks}
` : '', + }), { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); +}