diff --git a/.memory/worklog.json b/.memory/worklog.json
index 76173dc..6e4be9e 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -710,6 +710,72 @@
"message": "auto-save 2026-05-19 13:56 (+1, ~1)",
"hash": "cdda350",
"files_changed": 2
+ },
+ {
+ "ts": "2026-05-19T13:58:09+08:00",
+ "type": "commit",
+ "message": "feat: add generated image gallery",
+ "hash": "8ddda6a",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-19T06:00:02Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:feat: add generated image gallery",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-19T14:07:36+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-19 14:07 (+1, ~1)",
+ "hash": "eaed492",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-19T06:10:02Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:07 (+1, ~1)",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-19T14:13:02+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-19 14:13 (~14)",
+ "hash": "d327949",
+ "files_changed": 14
+ },
+ {
+ "ts": "2026-05-19T14:18:28+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-19 14:18 (+1, ~10)",
+ "hash": "49db765",
+ "files_changed": 11
+ },
+ {
+ "ts": "2026-05-19T06:20:02Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:18 (+1, ~10)",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-19T14:29:21+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-19 14:29 (~2)",
+ "hash": "6dfcd08",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-19T06:30:02Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:29 (~2)",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-19T14:31:16+08:00",
+ "type": "commit",
+ "message": "feat: add audit database and safer image review",
+ "hash": "a4fffd4",
+ "files_changed": 23
}
]
}
diff --git a/Dockerfile b/Dockerfile
index bd9a7b6..55cf265 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -17,6 +17,7 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=4560
+RUN apk add --no-cache sqlite
RUN addgroup -S nextjs -g 1001 && adduser -S nextjs -u 1001
COPY --from=builder --chown=nextjs:nextjs /app/package.json /app/package-lock.json ./
COPY --from=builder --chown=nextjs:nextjs /app/node_modules ./node_modules
diff --git a/RULES.md b/RULES.md
index 31556b8..9753033 100644
--- a/RULES.md
+++ b/RULES.md
@@ -23,6 +23,8 @@
## 元数据回写清单
- 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节
- 数据持久化在 `data/`(gitignored),不入库;上传原图在 `data/uploads/`
+- 后端审计库:`data/app.db`(SQLite);Docker 镜像内置 `sqlite3`,记录上传、生成、选择、角色锁定、素材包进度、重做、视频提交、图库/记录查看等事件
+- 审计兜底:非 Docker 本地如果缺少 `sqlite3`,写入 `data/audit-fallback.jsonl`,不阻断生成流程
- 本地 Docker 使用 `docker-compose.yml`,挂载 `./data:/app/data`,读取 `.env.local`,并强制 `PUBLIC_APP_URL=http://localhost:4560`
- VPS 生产 Docker 使用 `docker-compose.prod.yml`,挂载 `./data:/app/data`,读取 `deploy/.env.production`,并强制 `PUBLIC_APP_URL=https://ai-toy.kang-kang.com`
- VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data`
@@ -55,6 +57,9 @@
- L3:包内其它图,基于对应 L2 根图生成
- pack 图像生成必须走真实图生图:读取 anchor 图片字节后调用 GPT image edit,不再把参考图 URL 当纯文本拼进 prompt
- 单张重做接口:`POST /api/assets/[assetId]/regenerate`,必须沿用该图的 anchor
+- 单张重做需要 `confirmCost=true`;前端会二次确认,服务端会拒绝未确认请求,并对同一 session/asset 加并发锁防连点烧钱
+- 生成图库:`/api/gallery/[sessionId]`,按真实图片宽高比例展示缩略图,鼠标悬停显示大图;同页可跳转操作记录
+- 操作记录:`/api/audit/[sessionId]`,读取 `data/app.db` 展示事件流水和图片索引
- 视频参考优先级:宣发白底图 `mkt_white_front` → 专利主图 `patent_front` → L1 白底锚图 → L0 意向图
- 上传 API:`POST /api/uploads`,multipart 图片存入 `data/uploads/`,返回 `UploadedImage`
- 复刻建项目 API:`POST /api/projects/from-upload`,`mode=replicate` 时跳过批量生图,创建 selected L0,Vision 推断 `CharacterSpec`,并用 strict L1 净化 prompt
diff --git a/src/app/api/assets/[assetId]/regenerate/route.ts b/src/app/api/assets/[assetId]/regenerate/route.ts
index 774db73..3379d31 100644
--- a/src/app/api/assets/[assetId]/regenerate/route.ts
+++ b/src/app/api/assets/[assetId]/regenerate/route.ts
@@ -1,5 +1,8 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
+import { startGenerationLock } from '@/lib/generationLocks';
import { regeneratePackAsset } from '@/lib/packGenerator';
+import { detectProvider } from '@/lib/providers';
import { loadSession, saveSession } from '@/lib/storage';
import type { RegenerateAssetRequest, RegenerateAssetResponse } from '@/lib/types';
@@ -8,24 +11,69 @@ export const dynamic = 'force-dynamic';
export async function POST(req: Request, ctx: { params: Promise<{ assetId: string }> }) {
const { assetId } = await ctx.params;
- const { sessionId, userRefinement } = (await req.json()) as RegenerateAssetRequest;
+ const { sessionId, userRefinement, confirmCost } = (await req.json()) as RegenerateAssetRequest;
if (!assetId || !sessionId) {
return NextResponse.json({ error: 'assetId and sessionId required' }, { status: 400 });
}
+ if (confirmCost !== true) {
+ recordEvent({
+ action: 'asset.regenerate_blocked_unconfirmed',
+ sessionId,
+ targetType: 'asset',
+ targetId: assetId,
+ status: 'blocked',
+ provider: detectProvider(),
+ });
+ return NextResponse.json({ error: 'confirmCost required for paid regenerate' }, { status: 400 });
+ }
const session = await loadSession(sessionId);
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
+ const releaseLock = startGenerationLock(`regenerate:${sessionId}:${assetId}`);
+ if (!releaseLock) {
+ recordEvent({
+ action: 'asset.regenerate_blocked_running',
+ sessionId,
+ targetType: 'asset',
+ targetId: assetId,
+ status: 'blocked',
+ provider: detectProvider(),
+ });
+ return NextResponse.json({ error: 'asset regenerate already running' }, { status: 429 });
+ }
+
try {
+ recordEvent({
+ action: 'asset.regenerate_started',
+ sessionId,
+ targetType: 'asset',
+ targetId: assetId,
+ status: 'started',
+ provider: detectProvider(),
+ metadata: { refinement: Boolean(userRefinement?.trim()) },
+ });
const regenerated = await regeneratePackAsset({ session, assetId, userRefinement });
await saveSession(session);
+ recordEvent({
+ action: 'asset.regenerate_completed',
+ sessionId,
+ targetType: 'asset',
+ targetId: assetId,
+ status: 'ok',
+ provider: regenerated.provider,
+ metadata: { packId: regenerated.pack.id, templateId: regenerated.asset.templateId, url: regenerated.asset.url },
+ });
return NextResponse.json({
asset: regenerated.asset,
pack: regenerated.pack,
provider: regenerated.provider,
} satisfies RegenerateAssetResponse);
} catch (error) {
+ recordEvent({ action: 'asset.regenerate_failed', sessionId, targetType: 'asset', targetId: assetId, status: 'error', provider: detectProvider(), message: String(error) });
return NextResponse.json({ error: String(error) }, { status: 500 });
+ } finally {
+ releaseLock();
}
}
diff --git a/src/app/api/audit/[sessionId]/route.ts b/src/app/api/audit/[sessionId]/route.ts
new file mode 100644
index 0000000..1a800a3
--- /dev/null
+++ b/src/app/api/audit/[sessionId]/route.ts
@@ -0,0 +1,135 @@
+import { NextResponse } from 'next/server';
+import { listAuditEvents, listImageAssets, recordEvent } from '@/lib/auditDb';
+import { loadSession } from '@/lib/storage';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+function escapeHtml(input: string): string {
+ return input
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function formatTime(ts: number): string {
+ return new Date(ts).toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' });
+}
+
+function compactMetadata(metadata: string | null): string {
+ if (!metadata) return '';
+ try {
+ const parsed = JSON.parse(metadata) as unknown;
+ const raw = JSON.stringify(parsed);
+ return raw.length > 260 ? `${raw.slice(0, 260)}...` : raw;
+ } catch {
+ return metadata.length > 260 ? `${metadata.slice(0, 260)}...` : metadata;
+ }
+}
+
+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 });
+
+ recordEvent({ action: 'audit.view', sessionId, targetType: 'session', targetId: sessionId, status: 'ok' });
+ const events = listAuditEvents(sessionId, 500);
+ const assets = listImageAssets(sessionId, 1000);
+ const imageRows = assets.map(asset => `
+
+ | ${escapeHtml(asset.filename)} |
+ ${escapeHtml(asset.bucket)} |
+ ${escapeHtml(asset.kind ?? '')} |
+ ${escapeHtml(asset.template_id ?? asset.target_id ?? '')} |
+ ${asset.width && asset.height ? `${asset.width}×${asset.height}` : ''} |
+ ${asset.current ? '当前' : '历史'} |
+ ${escapeHtml(asset.status ?? '')} |
+ ${formatTime(asset.updated_at)} |
+
+ `).join('');
+
+ const eventRows = events.map(event => `
+
+ | ${formatTime(event.ts)} |
+ ${escapeHtml(event.status)} |
+ ${escapeHtml(event.action)} |
+ ${escapeHtml(event.target_type ?? '')} |
+ ${escapeHtml(event.target_id ?? '')} |
+ ${escapeHtml(event.provider ?? '')} |
+ ${escapeHtml(event.message ?? compactMetadata(event.metadata))} |
+
+ `).join('');
+
+ const html = `
+
+
+
+
+ ${escapeHtml(sessionId)} Audit
+
+
+
+
+
+ 图片索引
+ ${assets.length ? `
+ | 文件 | 桶 | 包 | 槽位/目标 | 真实尺寸 | 状态 | 资产状态 | 更新时间 |
+ ${imageRows}
+
` : '暂无图片索引。
'}
+
+ 操作事件
+ ${events.length ? `
+ | 时间 | 状态 | 动作 | 目标类型 | 目标 ID | Provider | 信息 |
+ ${eventRows}
+
` : '暂无操作记录。
'}
+
+`;
+
+ return new NextResponse(html, {
+ headers: {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ },
+ });
+}
diff --git a/src/app/api/character/cleanup/route.ts b/src/app/api/character/cleanup/route.ts
index ff25ca2..a77f166 100644
--- a/src/app/api/character/cleanup/route.ts
+++ b/src/app/api/character/cleanup/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
import { loadSession, saveSession } from '@/lib/storage';
import type { CleanupCharacterRequest, CleanupCharacterResponse } from '@/lib/types';
@@ -20,12 +21,22 @@ export async function POST(req: Request) {
if (!sourceImage) return NextResponse.json({ error: 'image not found' }, { status: 404 });
try {
+ recordEvent({ action: 'character.cleanup_started', sessionId, targetType: 'image', targetId: imageId, status: 'started', metadata: { force, preserveLevel } });
const characterSpec = session.characterSpec?.sourceImageId === imageId
? session.characterSpec
: await buildCharacterSpec(session, sourceImage);
const cleaned = await cleanupCharacterAnchor({ session, sourceImage, characterSpec, force, preserveLevel });
session.characterSpec = cleaned.characterSpec;
await saveSession(session);
+ recordEvent({
+ action: 'character.cleanup_completed',
+ sessionId,
+ targetType: 'image',
+ targetId: imageId,
+ status: 'ok',
+ provider: cleaned.provider,
+ metadata: { preserveLevel, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl },
+ });
return NextResponse.json({
characterSpec: cleaned.characterSpec,
@@ -33,6 +44,7 @@ export async function POST(req: Request) {
provider: cleaned.provider,
} satisfies CleanupCharacterResponse);
} catch (error) {
+ recordEvent({ action: 'character.cleanup_failed', sessionId, targetType: 'image', targetId: imageId, status: 'error', message: String(error) });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
diff --git a/src/app/api/character/lock-from-upload/route.ts b/src/app/api/character/lock-from-upload/route.ts
index e0161f7..0485003 100644
--- a/src/app/api/character/lock-from-upload/route.ts
+++ b/src/app/api/character/lock-from-upload/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
import { detectProvider } from '@/lib/providers';
import { loadSession, saveSession } from '@/lib/storage';
@@ -23,6 +24,7 @@ export async function POST(req: Request) {
if (!sourceImage) return NextResponse.json({ error: 'subject image not found' }, { status: 404 });
if (!force && session.characterSpec?.sourceImageId === sourceImage.id && session.characterSpec.cleanReferenceImageUrl) {
+ recordEvent({ action: 'character.lock_from_upload_cached', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'ok', provider: detectProvider() });
return NextResponse.json({
characterSpec: session.characterSpec,
provider: detectProvider(),
@@ -30,6 +32,7 @@ export async function POST(req: Request) {
}
try {
+ recordEvent({ action: 'character.lock_from_upload_started', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'started', provider: detectProvider(), metadata: { force, userHint: Boolean(userHint?.trim()) } });
if (userHint?.trim()) session.prompt = userHint.trim();
const characterSpec = await buildCharacterSpec(session, sourceImage);
const cleaned = await cleanupCharacterAnchor({
@@ -41,12 +44,22 @@ export async function POST(req: Request) {
});
session.characterSpec = cleaned.characterSpec;
await saveSession(session);
+ recordEvent({
+ action: 'character.lock_from_upload_completed',
+ sessionId,
+ targetType: 'upload',
+ targetId: subjectImageId,
+ status: 'ok',
+ provider: cleaned.provider,
+ metadata: { name: cleaned.characterSpec.name, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl },
+ });
return NextResponse.json({
characterSpec: cleaned.characterSpec,
provider: cleaned.provider,
} satisfies LockCharacterResponse);
} catch (error) {
+ recordEvent({ action: 'character.lock_from_upload_failed', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'error', provider: detectProvider(), message: String(error) });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
diff --git a/src/app/api/character/lock/route.ts b/src/app/api/character/lock/route.ts
index 726440a..05bb78a 100644
--- a/src/app/api/character/lock/route.ts
+++ b/src/app/api/character/lock/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
import { detectProvider } from '@/lib/providers';
import { loadSession, saveSession } from '@/lib/storage';
@@ -25,10 +26,12 @@ export async function POST(req: Request) {
characterSpec: session.characterSpec,
provider: detectProvider(),
};
+ recordEvent({ action: 'character.lock_cached', sessionId, targetType: 'image', targetId: imageId, status: 'ok', provider: response.provider });
return NextResponse.json(response);
}
try {
+ recordEvent({ action: 'character.lock_started', sessionId, targetType: 'image', targetId: imageId, status: 'started', provider: detectProvider(), metadata: { force } });
const characterSpec = await buildCharacterSpec(session, sourceImage);
const cleaned = await cleanupCharacterAnchor({
session,
@@ -43,8 +46,18 @@ export async function POST(req: Request) {
characterSpec: cleaned.characterSpec,
provider: detectProvider(),
};
+ recordEvent({
+ action: 'character.lock_completed',
+ sessionId,
+ targetType: 'image',
+ targetId: imageId,
+ status: 'ok',
+ provider: response.provider,
+ metadata: { name: cleaned.characterSpec.name, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl },
+ });
return NextResponse.json(response);
} catch (error) {
+ recordEvent({ action: 'character.lock_failed', sessionId, targetType: 'image', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error) });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
diff --git a/src/app/api/gallery/[sessionId]/route.ts b/src/app/api/gallery/[sessionId]/route.ts
index 05cfb4a..8b2c101 100644
--- a/src/app/api/gallery/[sessionId]/route.ts
+++ b/src/app/api/gallery/[sessionId]/route.ts
@@ -1,7 +1,9 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { NextResponse } from 'next/server';
+import { imageFileInfo, recordEvent, upsertImageAsset } from '@/lib/auditDb';
import { loadSession } from '@/lib/storage';
+import type { AssetPack, ToyAsset } from '@/lib/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -14,6 +16,11 @@ type GalleryItem = {
sizeKb: number;
mtime: number;
current: boolean;
+ width: number | null;
+ height: number | null;
+ aspectRatio?: string;
+ packId?: string;
+ templateId?: string;
};
function escapeHtml(input: string): string {
@@ -25,6 +32,11 @@ function escapeHtml(input: string): string {
.replace(/'/g, ''');
}
+type CurrentAssetMeta = {
+ pack: AssetPack;
+ asset: ToyAsset;
+};
+
function packGroup(filename: string): string {
const match = filename.match(/^pack_([^_]+)_/);
return match?.[1] ?? 'unknown';
@@ -37,7 +49,14 @@ function fileLabel(filename: string): string {
.replace(/_/g, ' ');
}
-async function listPackItems(currentFilenames: Set): Promise {
+function cssRatio(item: GalleryItem): string {
+ if (item.width && item.height) return `${item.width} / ${item.height}`;
+ if (item.aspectRatio && item.aspectRatio !== 'long') return item.aspectRatio.replace(':', ' / ');
+ if (item.aspectRatio === 'long') return '1 / 3';
+ return '1 / 1';
+}
+
+async function listPackItems(currentAssets: Map, sessionId: string): Promise {
const dir = path.join(process.cwd(), 'data', 'packs');
let files: string[] = [];
try {
@@ -50,16 +69,43 @@ async function listPackItems(currentFilenames: Set): Promise /\.(png|jpe?g|webp)$/i.test(filename))
.map(async filename => {
- const stat = await fs.stat(path.join(dir, filename));
- return {
+ const filePath = path.join(dir, filename);
+ const stat = await fs.stat(filePath);
+ const info = imageFileInfo(filePath);
+ const meta = currentAssets.get(filename);
+ const item = {
filename,
url: `/api/img/packs/${encodeURIComponent(filename)}`,
- group: packGroup(filename),
- label: fileLabel(filename),
+ group: meta?.pack.kind ?? packGroup(filename),
+ label: meta?.asset.title ?? fileLabel(filename),
sizeKb: Math.round(stat.size / 1024),
mtime: stat.mtimeMs,
- current: currentFilenames.has(filename),
+ current: Boolean(meta),
+ width: info.width,
+ height: info.height,
+ aspectRatio: meta?.asset.aspectRatio,
+ packId: meta?.pack.id,
+ templateId: meta?.asset.templateId,
} satisfies GalleryItem;
+ upsertImageAsset({
+ filename,
+ url: item.url,
+ bucket: 'packs',
+ sessionId,
+ packId: meta?.pack.id,
+ targetId: meta?.asset.id,
+ kind: meta?.pack.kind ?? packGroup(filename),
+ templateId: meta?.asset.templateId,
+ title: item.label,
+ aspectRatio: item.aspectRatio,
+ width: item.width,
+ height: item.height,
+ sizeBytes: stat.size,
+ current: Boolean(meta),
+ origin: meta ? 'current' : 'archived',
+ status: meta?.asset.status ?? 'archived',
+ });
+ return item;
}),
);
@@ -72,14 +118,15 @@ async function listPackItems(currentFilenames: Set): Promise `
-
+
-
+
+
${escapeHtml(item.group)} · ${escapeHtml(item.label)}
${escapeHtml(item.filename)}
- ${item.sizeKb} KB
+ ${item.width && item.height ? `${item.width}×${item.height} · ` : ''}${item.sizeKb} KB
`).join('');
@@ -98,6 +145,7 @@ function renderPage(opts: {
archived: GalleryItem[];
packsSummary: string;
sourceHtml: string;
+ auditUrl: string;
}): string {
const total = opts.current.length + opts.archived.length;
return `
@@ -117,9 +165,15 @@ function renderPage(opts: {
.pill { border: 1px solid #2d2f36; border-radius: 999px; padding: 6px 10px; background: #15161b; color: #d1d5db; font-size: 12px; }
.source { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; }
.source a { color: #f5f5f5; text-decoration: none; border: 1px solid #2d2f36; border-radius: 10px; padding: 8px 10px; background: #15161b; }
+ .toplinks { display: flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 10px; }
+ .toplinks a { color: #f5f5f5; text-decoration: none; border: 1px solid #3b3e47; border-radius: 10px; padding: 8px 10px; background: #1e2027; font-size: 12px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; }
- .card { background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: hidden; }
- .card img { width: 100%; aspect-ratio: 1 / 1; object-fit: contain; display: block; background: #fff; }
+ .card { position: relative; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: visible; }
+ .card a { position: relative; display: block; border-radius: 12px 12px 0 0; background: #fff; overflow: hidden; }
+ .thumb { width: 100%; aspect-ratio: var(--ratio, 1 / 1); object-fit: contain; display: block; background: #fff; }
+ .zoom { display: none; position: absolute; left: 50%; top: 10px; width: min(620px, 82vw); max-height: 82vh; object-fit: contain; transform: translate(-50%, -6%); z-index: 100; background: #fff; border: 1px solid #3b3e47; border-radius: 14px; box-shadow: 0 24px 90px rgba(0,0,0,.72); pointer-events: none; }
+ .card:hover { z-index: 20; border-color: #565b68; }
+ .card:hover .zoom { display: block; }
.meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; }
.meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; }
.empty { color: #9ca3af; }
@@ -132,11 +186,14 @@ function renderPage(opts: {
Session: ${escapeHtml(opts.sessionId)}
${opts.sourceHtml}
-
-
总图 ${total}
-
当前有效 ${opts.current.length}
-
历史未挂载 ${opts.archived.length}
- ${opts.packsSummary}
+
+
+ 总图 ${total}
+ 当前有效 ${opts.current.length}
+ 历史未挂载 ${opts.archived.length}
+ ${opts.packsSummary}
+
+
${renderItems('当前 session 有效图', opts.current)}
@@ -154,14 +211,15 @@ export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: str
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);
+ recordEvent({ action: 'gallery.view', sessionId, targetType: 'session', targetId: sessionId, status: 'ok' });
+ const currentAssets = new Map
();
+ for (const pack of session.packs ?? []) {
+ for (const asset of pack.assets ?? []) {
+ const filename = asset.url?.match(/\/api\/img\/packs\/([^/?#]+)$/)?.[1];
+ if (filename) currentAssets.set(decodeURIComponent(filename), { pack, asset });
+ }
+ }
+ const allItems = await listPackItems(currentAssets, sessionId);
const sourceLinks = [
session.characterSpec?.cleanReferenceImageUrl ? `L1 白底锚图` : '',
...(session.uploadedImages ?? []).map(upload => `上传图 ${escapeHtml(upload.originalFilename ?? upload.filename)}`),
@@ -176,6 +234,7 @@ export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: str
archived: allItems.filter(item => !item.current),
packsSummary,
sourceHtml: sourceLinks ? `${sourceLinks}
` : '',
+ auditUrl: `/api/audit/${encodeURIComponent(sessionId)}`,
}), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts
index 23dc908..b2f8e43 100644
--- a/src/app/api/generate/route.ts
+++ b/src/app/api/generate/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
+import { recordEvent } from '@/lib/auditDb';
import { detectProvider, generateGptImages, generateMock } from '@/lib/providers';
import { saveSession, saveGeneratedImage, saveRefImage } from '@/lib/storage';
import type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types';
@@ -18,6 +19,15 @@ export async function POST(req: Request) {
const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
const finalPrompt = style ? `${prompt}, style: ${style}` : prompt;
const provider = detectProvider();
+ recordEvent({
+ action: 'idea.generate_started',
+ sessionId,
+ targetType: 'session',
+ targetId: sessionId,
+ status: 'started',
+ provider,
+ metadata: { count, refImages: refImages.length, style: style ?? null, promptLength: finalPrompt.length },
+ });
const savedRefUrls: string[] = [];
for (let i = 0; i < refImages.length; i++) {
@@ -35,6 +45,16 @@ export async function POST(req: Request) {
? await generateGptImages({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls })
: await generateMock({ sessionId, prompt: finalPrompt, count });
} catch (e) {
+ recordEvent({
+ action: 'idea.generate_failed',
+ sessionId,
+ targetType: 'session',
+ targetId: sessionId,
+ status: 'error',
+ provider,
+ message: String(e),
+ metadata: { count },
+ });
return NextResponse.json({ error: String(e) }, { status: 500 });
}
@@ -56,6 +76,15 @@ export async function POST(req: Request) {
images,
};
await saveSession(session);
+ recordEvent({
+ action: 'idea.generate_completed',
+ sessionId,
+ targetType: 'session',
+ targetId: sessionId,
+ status: 'ok',
+ provider,
+ metadata: { images: images.length },
+ });
const resp: GenerateResponse = { sessionId, images, provider };
return NextResponse.json(resp);
diff --git a/src/app/api/packs/generate-all/route.ts b/src/app/api/packs/generate-all/route.ts
index a180a0e..8cfdcb5 100644
--- a/src/app/api/packs/generate-all/route.ts
+++ b/src/app/api/packs/generate-all/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { startGenerationLock } from '@/lib/generationLocks';
import { generateAssetPack } from '@/lib/packGenerator';
import { detectProvider } from '@/lib/providers';
@@ -44,6 +45,14 @@ export async function POST(req: Request) {
const baseSourceImage = sourceImage;
const releaseAllLock = startGenerationLock(`packs:all:${sessionId}:${imageId}`);
if (!releaseAllLock) {
+ recordEvent({
+ action: 'packs.generate_all_blocked_running',
+ sessionId,
+ targetType: 'pack_all',
+ targetId: imageId,
+ status: 'blocked',
+ provider: detectProvider(),
+ });
return NextResponse.json({
ok: true,
background: true,
@@ -59,9 +68,11 @@ export async function POST(req: Request) {
let workingSession: GenSession = baseSession;
try {
+ recordEvent({ action: 'packs.generate_all_started', sessionId, targetType: 'pack_all', targetId: imageId, status: 'started', provider: detectProvider(), metadata: { background } });
for (const kind of PACK_ORDER) {
const existingPack = workingSession.packs?.find(pack => pack.kind === kind && isCompletePack(pack, imageId));
if (existingPack) {
+ recordEvent({ action: 'pack.generate_skipped_existing', sessionId, targetType: 'pack', targetId: existingPack.id, status: 'ok', provider: detectProvider(), metadata: { kind, assets: existingPack.assets.length } });
const existingManifest = workingSession.exports?.find(manifest => (
manifest.packKind === kind &&
manifest.source.sourceImageId === imageId &&
@@ -73,15 +84,23 @@ export async function POST(req: Request) {
}
const releasePackLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
- if (!releasePackLock) continue;
+ if (!releasePackLock) {
+ recordEvent({ action: 'pack.generate_blocked_running', sessionId, targetType: 'pack', targetId: kind, status: 'blocked', provider: detectProvider(), metadata: { imageId, fromAll: true } });
+ continue;
+ }
try {
+ recordEvent({ action: 'pack.generate_started', sessionId, targetType: 'pack', targetId: kind, status: 'started', provider: detectProvider(), metadata: { imageId, fromAll: true } });
const generated = await generateAssetPack({
session: workingSession,
sourceImage: baseSourceImage,
kind,
- onProgress: progressPack => persistPackProgress(workingSession, imageId, progressPack),
+ onProgress: async progressPack => {
+ await persistPackProgress(workingSession, imageId, progressPack);
+ recordEvent({ action: 'pack.generate_progress', sessionId, targetType: 'pack', targetId: progressPack.id, status: 'running', provider: detectProvider(), metadata: { kind, assets: progressPack.assets.length, packStatus: progressPack.status, fromAll: true } });
+ },
});
+ recordEvent({ action: 'pack.generate_completed', sessionId, targetType: 'pack', targetId: generated.pack.id, status: 'ok', provider: generated.provider, metadata: { kind, assets: generated.pack.assets.length, fromAll: true } });
packs.push(generated.pack);
manifests.push(generated.manifest);
workingSession = {
@@ -102,6 +121,7 @@ export async function POST(req: Request) {
}
await saveSession(workingSession);
+ recordEvent({ action: 'packs.generate_all_completed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'ok', provider: detectProvider(), metadata: { packs: packs.length, manifests: manifests.length } });
return {
packs,
@@ -114,7 +134,10 @@ export async function POST(req: Request) {
}
if (background) {
- void run().catch(error => console.error('[packs:all] background generation failed', error));
+ void run().catch(error => {
+ recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: true } });
+ console.error('[packs:all] background generation failed', error);
+ });
return NextResponse.json({
ok: true,
background: true,
@@ -125,6 +148,7 @@ export async function POST(req: Request) {
try {
return NextResponse.json(await run());
} catch (error) {
+ recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: false } });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
diff --git a/src/app/api/packs/generate/route.ts b/src/app/api/packs/generate/route.ts
index d5dd1ac..e30b0d6 100644
--- a/src/app/api/packs/generate/route.ts
+++ b/src/app/api/packs/generate/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { startGenerationLock } from '@/lib/generationLocks';
import { generateAssetPack } from '@/lib/packGenerator';
import { detectProvider } from '@/lib/providers';
@@ -38,6 +39,15 @@ export async function POST(req: Request) {
const baseSourceImage = sourceImage;
const releaseLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
if (!releaseLock) {
+ recordEvent({
+ action: 'pack.generate_blocked_running',
+ sessionId,
+ targetType: 'pack',
+ targetId: kind,
+ status: 'blocked',
+ provider: detectProvider(),
+ metadata: { imageId, kind },
+ });
return NextResponse.json({
ok: true,
background: true,
@@ -50,11 +60,31 @@ export async function POST(req: Request) {
async function run() {
try {
+ recordEvent({
+ action: 'pack.generate_started',
+ sessionId,
+ targetType: 'pack',
+ targetId: kind,
+ status: 'started',
+ provider: detectProvider(),
+ metadata: { imageId, background },
+ });
const { pack, manifest, provider } = await generateAssetPack({
session: baseSession,
sourceImage: baseSourceImage,
kind,
- onProgress: progressPack => persistPackProgress(baseSession, imageId, progressPack),
+ onProgress: async progressPack => {
+ await persistPackProgress(baseSession, imageId, progressPack);
+ recordEvent({
+ action: 'pack.generate_progress',
+ sessionId,
+ targetType: 'pack',
+ targetId: progressPack.id,
+ status: 'running',
+ provider: detectProvider(),
+ metadata: { kind, assets: progressPack.assets.length, packStatus: progressPack.status },
+ });
+ },
});
baseSession.characterSpec = pack.characterSpec;
baseSession.packs = [
@@ -66,6 +96,15 @@ export async function POST(req: Request) {
manifest,
];
await saveSession(baseSession);
+ recordEvent({
+ action: 'pack.generate_completed',
+ sessionId,
+ targetType: 'pack',
+ targetId: pack.id,
+ status: 'ok',
+ provider,
+ metadata: { kind, assets: pack.assets.length, manifestId: manifest.id },
+ });
return { pack, manifest, provider } satisfies GeneratePackResponse;
} finally {
release();
@@ -73,7 +112,10 @@ export async function POST(req: Request) {
}
if (background) {
- void run().catch(error => console.error(`[pack:${kind}] background generation failed`, error));
+ void run().catch(error => {
+ recordEvent({ action: 'pack.generate_failed', sessionId, targetType: 'pack', targetId: kind, status: 'error', provider: detectProvider(), message: String(error), metadata: { imageId, background: true } });
+ console.error(`[pack:${kind}] background generation failed`, error);
+ });
return NextResponse.json({
ok: true,
background: true,
@@ -87,6 +129,7 @@ export async function POST(req: Request) {
return NextResponse.json(response);
} catch (error) {
+ recordEvent({ action: 'pack.generate_failed', sessionId, targetType: 'pack', targetId: kind, status: 'error', provider: detectProvider(), message: String(error), metadata: { imageId, background: false } });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
diff --git a/src/app/api/projects/from-upload/route.ts b/src/app/api/projects/from-upload/route.ts
index a750aeb..07215c2 100644
--- a/src/app/api/projects/from-upload/route.ts
+++ b/src/app/api/projects/from-upload/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
+import { recordEvent } from '@/lib/auditDb';
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
import { detectProvider, generateGptImageEdit, generateMock } from '@/lib/providers';
import { saveGeneratedImage, saveSession } from '@/lib/storage';
@@ -155,12 +156,36 @@ export async function POST(req: Request) {
}
const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
+ recordEvent({
+ action: 'upload_project.create_started',
+ sessionId,
+ targetType: 'session',
+ targetId: sessionId,
+ status: 'started',
+ provider: detectProvider(),
+ metadata: { mode: body.mode, uploads: body.uploadedImages.length, count: body.count ?? null },
+ });
const response = body.mode === 'remix'
? await createRemixSession(body, sessionId)
: await createReplicateOrExtendSession(body, sessionId);
+ recordEvent({
+ action: 'upload_project.create_completed',
+ sessionId,
+ targetType: 'session',
+ targetId: sessionId,
+ status: 'ok',
+ provider: response.provider,
+ metadata: { mode: body.mode, images: response.images?.length ?? 1, preFilledSlots: response.preFilledSlots?.length ?? 0 },
+ });
return NextResponse.json(response satisfies ProjectFromUploadResponse);
} catch (error) {
+ recordEvent({
+ action: 'upload_project.create_failed',
+ status: 'error',
+ provider: detectProvider(),
+ message: String(error),
+ });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
diff --git a/src/app/api/select/route.ts b/src/app/api/select/route.ts
index 1f80fda..0f3d964 100644
--- a/src/app/api/select/route.ts
+++ b/src/app/api/select/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { copyToSelected, loadSession, saveSession } from '@/lib/storage';
export const runtime = 'nodejs';
@@ -26,5 +27,13 @@ export async function POST(req: Request) {
img.status = 'pending';
}
await saveSession(session);
+ recordEvent({
+ action: `image.${action}`,
+ sessionId,
+ targetType: 'image',
+ targetId: imageId,
+ status: 'ok',
+ metadata: { selectedUrl: img.meta?.selectedUrl ?? null },
+ });
return NextResponse.json({ ok: true, image: img });
}
diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts
index 79c3473..c562480 100644
--- a/src/app/api/uploads/route.ts
+++ b/src/app/api/uploads/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { saveUploadedImage } from '@/lib/storage';
import type { UploadImageResponse, UploadedImageRole } from '@/lib/types';
@@ -48,6 +49,13 @@ export async function POST(req: Request) {
accessoryName,
needsCleanup,
});
+ recordEvent({
+ action: 'upload.completed',
+ targetType: 'upload',
+ targetId: uploadedImage.id,
+ status: 'ok',
+ metadata: { filename: uploadedImage.filename, originalFilename: uploadedImage.originalFilename, role, size: file.size },
+ });
return NextResponse.json({ uploadedImage } satisfies UploadImageResponse);
}
diff --git a/src/app/api/video/generate/route.ts b/src/app/api/video/generate/route.ts
index b9cf320..e47702e 100644
--- a/src/app/api/video/generate/route.ts
+++ b/src/app/api/video/generate/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { generateSeedanceVideo } from '@/lib/videoProviders';
import type { VideoGenerationRequest } from '@/lib/types';
@@ -8,9 +9,13 @@ export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const body = (await req.json()) as VideoGenerationRequest;
try {
- return NextResponse.json(await generateSeedanceVideo(body));
+ recordEvent({ action: 'video.generate_started', targetType: 'video', status: 'started', provider: 'seedance', metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } });
+ const response = await generateSeedanceVideo(body);
+ recordEvent({ action: 'video.generate_submitted', targetType: 'video', targetId: response.taskId ?? response.status, status: 'queued', provider: 'seedance', metadata: { status: response.status } });
+ return NextResponse.json(response);
} catch (error) {
const message = String(error);
+ recordEvent({ action: 'video.generate_failed', targetType: 'video', status: 'error', provider: 'seedance', message });
return NextResponse.json({ error: message }, {
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
});
diff --git a/src/app/api/video/status/[taskId]/route.ts b/src/app/api/video/status/[taskId]/route.ts
index cf7ccf7..e5510e0 100644
--- a/src/app/api/video/status/[taskId]/route.ts
+++ b/src/app/api/video/status/[taskId]/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import { recordEvent } from '@/lib/auditDb';
import { getSeedanceVideoTask } from '@/lib/videoProviders';
export const runtime = 'nodejs';
@@ -7,9 +8,12 @@ export const dynamic = 'force-dynamic';
export async function GET(_req: Request, ctx: { params: Promise<{ taskId: string }> }) {
const { taskId } = await ctx.params;
try {
- return NextResponse.json(await getSeedanceVideoTask(taskId));
+ const response = await getSeedanceVideoTask(taskId);
+ recordEvent({ action: 'video.status_checked', targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } });
+ return NextResponse.json(response);
} catch (error) {
const message = String(error);
+ recordEvent({ action: 'video.status_failed', targetType: 'video', targetId: taskId, status: 'error', provider: 'seedance', message });
return NextResponse.json({ error: message }, {
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
});
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 5a6b450..8116de1 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -217,7 +217,7 @@ export default function Home() {
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sessionId: current.id, userRefinement }),
+ body: JSON.stringify({ sessionId: current.id, userRefinement, confirmCost: true }),
});
if (!r.ok) {
alert('单张重做失败:' + (await r.text()));
@@ -299,6 +299,26 @@ export default function Home() {
+ {current && (
+ <>
+
+ 图库
+
+
+ 记录
+
+ >
+ )}
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
diff --git a/src/components/PackPanel.tsx b/src/components/PackPanel.tsx
index 9d5043e..15ea6ad 100644
--- a/src/components/PackPanel.tsx
+++ b/src/components/PackPanel.tsx
@@ -27,6 +27,12 @@ const ASPECT_PX: Record = {
'long': '1024×3200',
};
+function aspectCss(aspectRatio: AssetTemplate['aspectRatio'] | string | undefined): string {
+ if (!aspectRatio) return '1 / 1';
+ if (aspectRatio === 'long') return '1 / 3';
+ return aspectRatio.replace(':', ' / ');
+}
+
function manifestUrl(sessionId: string, kind: PackKind, version: string) {
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
}
@@ -45,6 +51,8 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
const ready = !!asset;
async function handleRedo() {
if (!asset || !onRegenerate || regenerating) return;
+ const ok = window.confirm('重做这 1 张会重新调用图片模型并产生费用。确认重做?');
+ if (!ok) return;
setRegenerating(true);
try {
await onRegenerate(asset.id, refinement);
@@ -55,13 +63,18 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
}
}
return (
-
+
{/* thumbnail */}
-
+
{ready ? (
-

+ <>
+

+
+

+
+ >
) : (
-
+
{template.aspectRatio}
)}
@@ -99,6 +112,17 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
anchor: {asset.anchorAssetId ?? asset.anchorImageUrl}
)}
+ {ready && onRegenerate && (
+
+ 成本操作
+
+
+ )}
{showRedo && ready && (
- {regenerating ? '...' : '确认'}
+ {regenerating ? '...' : '确认付费重做'}
)}
@@ -125,14 +149,6 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
{ready ? `L${asset!.derivationLevel}` : '待生成'}
- {ready && onRegenerate && (
-
- )}
@@ -156,8 +172,16 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
const total = templates.length;
const progressPct = Math.round((generatedCount / total) * 100);
+ function handleGenerateClick() {
+ if (pack) {
+ const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重跑会重新调用图片模型并产生费用。确认继续?`);
+ if (!ok) return;
+ }
+ onGenerate();
+ }
+
return (
-
+
{/* header — always visible */}
@@ -191,7 +215,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
)}
-

+
主方案
@@ -523,7 +547,10 @@ export default function PackPanel({
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}