feat: add audit database and safer image review

This commit is contained in:
2026-05-19 14:31:16 +08:00
parent 8ddda6aa2b
commit 9ab7756369
23 changed files with 1159 additions and 64 deletions

View File

@@ -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
}
]
}

View File

@@ -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

View File

@@ -23,6 +23,8 @@
## 元数据回写清单
- 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节
- 数据持久化在 `data/`gitignored不入库上传原图在 `data/uploads/`
- 后端审计库:`data/app.db`SQLiteDocker 镜像内置 `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 L0Vision 推断 `CharacterSpec`,并用 strict L1 净化 prompt

View File

@@ -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();
}
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 => `
<tr>
<td><a href="${asset.url}" target="_blank" rel="noreferrer">${escapeHtml(asset.filename)}</a></td>
<td>${escapeHtml(asset.bucket)}</td>
<td>${escapeHtml(asset.kind ?? '')}</td>
<td>${escapeHtml(asset.template_id ?? asset.target_id ?? '')}</td>
<td>${asset.width && asset.height ? `${asset.width}×${asset.height}` : ''}</td>
<td>${asset.current ? '当前' : '历史'}</td>
<td>${escapeHtml(asset.status ?? '')}</td>
<td>${formatTime(asset.updated_at)}</td>
</tr>
`).join('');
const eventRows = events.map(event => `
<tr>
<td>${formatTime(event.ts)}</td>
<td><span class="status ${escapeHtml(event.status)}">${escapeHtml(event.status)}</span></td>
<td>${escapeHtml(event.action)}</td>
<td>${escapeHtml(event.target_type ?? '')}</td>
<td>${escapeHtml(event.target_id ?? '')}</td>
<td>${escapeHtml(event.provider ?? '')}</td>
<td>${escapeHtml(event.message ?? compactMetadata(event.metadata))}</td>
</tr>
`).join('');
const html = `<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(sessionId)} Audit</title>
<style>
:root { color-scheme: dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0b0d; color: #f5f5f5; }
body { margin: 0; padding: 28px; }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
h1 { margin: 0 0 8px; font-size: 24px; }
h2 { margin: 30px 0 12px; font-size: 18px; }
a { color: #c4b5fd; }
.muted { color: #9ca3af; }
.pillrow { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.pill { border: 1px solid #2d2f36; border-radius: 999px; padding: 6px 10px; background: #15161b; color: #d1d5db; font-size: 12px; }
table { width: 100%; border-collapse: collapse; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: hidden; }
th, td { padding: 9px 10px; border-bottom: 1px solid #252831; text-align: left; font-size: 12px; vertical-align: top; }
th { color: #d1d5db; background: #1d1f27; font-weight: 600; position: sticky; top: 0; }
td { color: #a7adbb; }
tr:hover td { background: #191c23; }
.status { display: inline-flex; border-radius: 999px; padding: 2px 7px; background: #2b2f38; color: #d1d5db; }
.status.ok { background: rgba(16,185,129,.18); color: #86efac; }
.status.error { background: rgba(239,68,68,.18); color: #fca5a5; }
.status.blocked { background: rgba(251,191,36,.18); color: #fde68a; }
.status.started, .status.running, .status.queued { background: rgba(59,130,246,.18); color: #93c5fd; }
.empty { color: #9ca3af; }
</style>
</head>
<body>
<header>
<div>
<h1>操作记录 / 数据库</h1>
<div class="muted">Session: ${escapeHtml(sessionId)}</div>
<div class="muted">每次上传、生成、选择、锁定、重做和图库查看都会记录在 <code>data/app.db</code></div>
</div>
<div>
<div class="pillrow">
<span class="pill">事件 ${events.length}</span>
<span class="pill">图片索引 ${assets.length}</span>
<span class="pill">当前图 ${assets.filter(asset => asset.current).length}</span>
</div>
<div style="margin-top:10px;text-align:right"><a href="/api/gallery/${encodeURIComponent(sessionId)}">返回图库</a></div>
</div>
</header>
<h2>图片索引</h2>
${assets.length ? `<table>
<thead><tr><th>文件</th><th>桶</th><th>包</th><th>槽位/目标</th><th>真实尺寸</th><th>状态</th><th>资产状态</th><th>更新时间</th></tr></thead>
<tbody>${imageRows}</tbody>
</table>` : '<p class="empty">暂无图片索引。</p>'}
<h2>操作事件</h2>
${events.length ? `<table>
<thead><tr><th>时间</th><th>状态</th><th>动作</th><th>目标类型</th><th>目标 ID</th><th>Provider</th><th>信息</th></tr></thead>
<tbody>${eventRows}</tbody>
</table>` : '<p class="empty">暂无操作记录。</p>'}
</body>
</html>`;
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}

View File

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

View File

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

View File

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

View File

@@ -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, '&#39;');
}
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<string>): Promise<GalleryItem[]> {
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<string, CurrentAssetMeta>, sessionId: string): Promise<GalleryItem[]> {
const dir = path.join(process.cwd(), 'data', 'packs');
let files: string[] = [];
try {
@@ -50,16 +69,43 @@ async function listPackItems(currentFilenames: Set<string>): Promise<GalleryItem
files
.filter(filename => /\.(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<string>): Promise<GalleryItem
function renderItems(title: string, items: GalleryItem[]): string {
const cards = items.map(item => `
<article class="card">
<article class="card" style="--ratio: ${cssRatio(item)}">
<a href="${item.url}" target="_blank" rel="noreferrer">
<img src="${item.url}" loading="lazy" alt="${escapeHtml(item.filename)}" />
<img class="thumb" src="${item.url}" loading="lazy" alt="${escapeHtml(item.filename)}" />
<img class="zoom" src="${item.url}" loading="lazy" alt="" />
</a>
<div class="meta">
<strong>${escapeHtml(item.group)} · ${escapeHtml(item.label)}</strong>
<span>${escapeHtml(item.filename)}</span>
<span>${item.sizeKb} KB</span>
<span>${item.width && item.height ? `${item.width}×${item.height} · ` : ''}${item.sizeKb} KB</span>
</div>
</article>
`).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 `<!doctype html>
@@ -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: {
<div class="muted">Session: ${escapeHtml(opts.sessionId)}</div>
${opts.sourceHtml}
</div>
<div class="pillrow">
<span class="pill">总图 ${total}</span>
<span class="pill">当前有效 ${opts.current.length}</span>
<span class="pill">历史未挂载 ${opts.archived.length}</span>
${opts.packsSummary}
<div>
<div class="pillrow">
<span class="pill">总图 ${total}</span>
<span class="pill">当前有效 ${opts.current.length}</span>
<span class="pill">历史未挂载 ${opts.archived.length}</span>
${opts.packsSummary}
</div>
<div class="toplinks"><a href="${opts.auditUrl}">操作记录 / 数据库</a></div>
</div>
</header>
${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<string, CurrentAssetMeta>();
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 ? `<a href="${session.characterSpec.cleanReferenceImageUrl}" target="_blank" rel="noreferrer">L1 白底锚图</a>` : '',
...(session.uploadedImages ?? []).map(upload => `<a href="${upload.url}" target="_blank" rel="noreferrer">上传图 ${escapeHtml(upload.originalFilename ?? upload.filename)}</a>`),
@@ -176,6 +234,7 @@ export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: str
archived: allItems.filter(item => !item.current),
packsSummary,
sourceHtml: sourceLinks ? `<div class="source">${sourceLinks}</div>` : '',
auditUrl: `/api/audit/${encodeURIComponent(sessionId)}`,
}), {
headers: {
'Content-Type': 'text/html; charset=utf-8',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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() {
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{current && (
<>
<a
href={`/api/gallery/${encodeURIComponent(current.id)}`}
target="_blank"
rel="noreferrer"
className="chip chip-neutral hover:border-violet-300/40 hover:text-white transition-colors"
>
</a>
<a
href={`/api/audit/${encodeURIComponent(current.id)}`}
target="_blank"
rel="noreferrer"
className="chip chip-neutral hover:border-violet-300/40 hover:text-white transition-colors"
>
</a>
</>
)}
<span className={provider === 'gpt' ? 'chip chip-live' : provider === '?' ? 'chip chip-neutral' : 'chip chip-mock'}>
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-400' : provider === '?' ? 'bg-white/40' : 'bg-amber-400'}`} />
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}

View File

@@ -27,6 +27,12 @@ const ASPECT_PX: Record<AssetTemplate['aspectRatio'], string> = {
'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 (
<div className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="grid grid-cols-[76px_1fr_auto] gap-3 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
{/* thumbnail */}
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/[0.04] ring-1 ring-white/[0.07] flex items-center justify-center">
<div className="group/thumb relative w-[72px] h-[72px] rounded-xl overflow-visible bg-white/[0.04] ring-1 ring-white/[0.07] flex items-center justify-center">
{ready ? (
<img src={asset!.url} alt={template.title} className="w-full h-full object-cover" />
<>
<img src={asset!.url} alt={template.title} className="max-w-full max-h-full object-contain rounded-lg bg-white" />
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[80] hidden -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-2 shadow-2xl ring-1 ring-white/20 group-hover/thumb:block" style={{ width: 'min(640px, 86vw)' }}>
<img src={asset!.url} alt="" className="max-h-[82vh] w-full object-contain" style={{ aspectRatio: aspectCss(asset!.aspectRatio) }} />
</div>
</>
) : (
<div className="flex flex-col items-center gap-1">
<div className="flex flex-col items-center gap-1 rounded-lg bg-black/25 px-2 py-1" style={{ aspectRatio: aspectCss(template.aspectRatio) }}>
<span className="text-[9px] text-white/30 font-mono">{template.aspectRatio}</span>
</div>
)}
@@ -99,6 +112,17 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
anchor: {asset.anchorAssetId ?? asset.anchorImageUrl}
</div>
)}
{ready && onRegenerate && (
<div className="mt-2 pt-2 border-t border-amber-400/10 flex items-center justify-between gap-3">
<span className="text-[10px] text-amber-200/45"></span>
<button
onClick={() => setShowRedo(value => !value)}
className="text-[10px] text-amber-200/70 hover:text-amber-100 rounded-lg px-2 py-1 ring-1 ring-amber-300/15 bg-amber-300/[0.04]"
>
{showRedo ? '收起重做' : '打开重做'}
</button>
</div>
)}
{showRedo && ready && (
<div className="flex items-center gap-2 pt-1">
<input
@@ -110,9 +134,9 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
<button
onClick={handleRedo}
disabled={regenerating}
className="btn btn-primary text-[10px] px-2 py-1 disabled:opacity-40"
className="rounded-lg bg-amber-500/80 hover:bg-amber-400 text-black text-[10px] px-2 py-1 disabled:opacity-40"
>
{regenerating ? '...' : '确认'}
{regenerating ? '...' : '确认付费重做'}
</button>
</div>
)}
@@ -125,14 +149,6 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
<span className={`text-[10px] ${ready ? 'text-emerald-300' : 'text-white/25'}`}>
{ready ? `L${asset!.derivationLevel}` : '待生成'}
</span>
{ready && onRegenerate && (
<button
onClick={() => setShowRedo(value => !value)}
className="text-[10px] text-violet-300 hover:text-violet-200"
>
</button>
)}
</div>
</div>
</div>
@@ -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 (
<section className="card overflow-hidden" id={`pack-${kind}`}>
<section className="card" id={`pack-${kind}`}>
{/* header — always visible */}
<div className="p-4 flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${accent.bar} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
@@ -191,7 +215,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
</a>
)}
<button
onClick={onGenerate}
onClick={handleGenerateClick}
disabled={isLoading}
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs px-3 py-1.5 disabled:opacity-40`}
>
@@ -199,7 +223,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
) : pack ? '重做' : `生成 ${total}`}
) : pack ? '危险重跑' : `生成 ${total}`}
</button>
<button
onClick={() => setOpen(v => !v)}
@@ -496,7 +520,7 @@ export default function PackPanel({
<div className="text-[10px]"></div>
</div>
<div className="relative w-16 h-16 rounded-2xl overflow-hidden ring-1 ring-white/15 shadow-glow-violet shrink-0">
<img src={primaryImage.url} alt="selected" className="w-full h-full object-cover" />
<img src={primaryImage.url} alt="selected" className="w-full h-full object-contain bg-white" />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<div className="absolute bottom-1 inset-x-0 text-center text-[8px] font-semibold text-white/80 uppercase tracking-wider"></div>
</div>
@@ -523,7 +547,10 @@ export default function PackPanel({
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
</button>
<button
onClick={() => onGenerateAll(primaryImage)}
onClick={() => {
const ok = window.confirm(`一键全包最多会生成 ${totalImageSlots} 张图片,费用会明显高于单张。确认启动?`);
if (ok) onGenerateAll(primaryImage);
}}
disabled={allLoading || !!loadingKind || characterLoading}
className="btn btn-primary text-xs disabled:opacity-40"
>

View File

@@ -56,7 +56,10 @@ export default function ResultGrid({ images, onAction }: ResultGridProps) {
key={img.id}
className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
>
<img src={img.url} alt={`gen ${i + 1}`} className="w-full h-full object-cover" />
<img src={img.url} alt={`gen ${i + 1}`} className="w-full h-full object-contain bg-white" />
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[80] hidden -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-2 shadow-2xl ring-1 ring-white/20 group-hover:block" style={{ width: 'min(640px, 86vw)' }}>
<img src={img.url} alt="" className="max-h-[82vh] w-full object-contain" />
</div>
<div className="tile-keynum">{i + 1}</div>
{img.status === 'selected' && (

308
src/lib/auditDb.ts Normal file
View File

@@ -0,0 +1,308 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'data');
const DB_PATH = path.join(DATA_DIR, 'app.db');
const FALLBACK_PATH = path.join(DATA_DIR, 'audit-fallback.jsonl');
type SqlValue = string | number | boolean | null | undefined;
export type AuditEventInput = {
action: string;
sessionId?: string | null;
targetType?: string | null;
targetId?: string | null;
status?: 'ok' | 'started' | 'queued' | 'running' | 'error' | 'blocked';
provider?: string | null;
message?: string | null;
metadata?: unknown;
};
export type AuditEventRow = {
id: number;
ts: number;
action: string;
session_id: string | null;
target_type: string | null;
target_id: string | null;
status: string;
provider: string | null;
message: string | null;
metadata: string | null;
};
export type ImageAssetInput = {
filename: string;
url: string;
bucket?: string;
sessionId?: string | null;
packId?: string | null;
targetId?: string | null;
kind?: string | null;
templateId?: string | null;
title?: string | null;
aspectRatio?: string | null;
width?: number | null;
height?: number | null;
sizeBytes?: number | null;
current?: boolean;
origin?: 'current' | 'archived' | 'generated' | 'selected' | 'upload' | 'anchor' | 'ref' | 'pack';
status?: string | null;
};
export type ImageAssetRow = {
filename: string;
url: string;
bucket: string;
session_id: string | null;
pack_id: string | null;
target_id: string | null;
kind: string | null;
template_id: string | null;
title: string | null;
aspect_ratio: string | null;
width: number | null;
height: number | null;
size_bytes: number | null;
current: number;
origin: string | null;
status: string | null;
created_at: number;
updated_at: number;
};
function ensureDataDir() {
mkdirSync(DATA_DIR, { recursive: true });
}
function q(value: SqlValue): string {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
if (typeof value === 'boolean') return value ? '1' : '0';
return `'${value.replace(/'/g, "''")}'`;
}
function executeSql(sql: string, readonly = false): string {
ensureDataDir();
try {
const args = readonly ? ['-readonly', DB_PATH] : [DB_PATH];
return execFileSync('sqlite3', args, {
input: `${sql}\n`,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
const line = JSON.stringify({
ts: Date.now(),
action: 'sqlite_unavailable',
message: String(error),
});
try {
writeFileSync(FALLBACK_PATH, `${line}\n`, { flag: 'a' });
} catch {
// Ignore audit fallback failures; product flow should not fail because logging failed.
}
throw error;
}
}
function columnExists(table: string, column: string): boolean {
try {
const output = execFileSync('sqlite3', ['-readonly', '-json', DB_PATH, `PRAGMA table_info(${table});`], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const rows = output ? JSON.parse(output) as Array<{ name?: string }> : [];
return rows.some(row => row.name === column);
} catch {
return false;
}
}
function ensureDb() {
executeSql(`
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
action TEXT NOT NULL,
session_id TEXT,
target_type TEXT,
target_id TEXT,
status TEXT NOT NULL DEFAULT 'ok',
provider TEXT,
message TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_events_session_ts ON audit_events(session_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_audit_events_action_ts ON audit_events(action, ts DESC);
CREATE TABLE IF NOT EXISTS image_assets (
filename TEXT PRIMARY KEY,
url TEXT NOT NULL,
bucket TEXT NOT NULL DEFAULT 'packs',
session_id TEXT,
pack_id TEXT,
target_id TEXT,
kind TEXT,
template_id TEXT,
title TEXT,
aspect_ratio TEXT,
width INTEGER,
height INTEGER,
size_bytes INTEGER,
current INTEGER NOT NULL DEFAULT 0,
origin TEXT,
status TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_image_assets_session ON image_assets(session_id, current, kind);
CREATE INDEX IF NOT EXISTS idx_image_assets_pack ON image_assets(pack_id);
`);
if (!columnExists('image_assets', 'target_id')) {
executeSql('ALTER TABLE image_assets ADD COLUMN target_id TEXT;');
}
}
export function recordEvent(input: AuditEventInput) {
try {
ensureDb();
executeSql(`
INSERT INTO audit_events (ts, action, session_id, target_type, target_id, status, provider, message, metadata)
VALUES (
${Date.now()},
${q(input.action)},
${q(input.sessionId)},
${q(input.targetType)},
${q(input.targetId)},
${q(input.status ?? 'ok')},
${q(input.provider)},
${q(input.message)},
${q(input.metadata === undefined ? null : JSON.stringify(input.metadata))}
);
`);
} catch {
try {
ensureDataDir();
writeFileSync(FALLBACK_PATH, `${JSON.stringify({ ts: Date.now(), ...input })}\n`, { flag: 'a' });
} catch {
// Logging must never break product actions.
}
}
}
export function upsertImageAsset(input: ImageAssetInput) {
try {
ensureDb();
const now = Date.now();
executeSql(`
INSERT INTO image_assets (
filename, url, bucket, session_id, pack_id, kind, template_id, title, aspect_ratio,
target_id, width, height, size_bytes, current, origin, status, created_at, updated_at
)
VALUES (
${q(input.filename)}, ${q(input.url)}, ${q(input.bucket ?? 'packs')}, ${q(input.sessionId)},
${q(input.packId)}, ${q(input.kind)}, ${q(input.templateId)}, ${q(input.title)},
${q(input.aspectRatio)}, ${q(input.targetId)}, ${q(input.width)}, ${q(input.height)}, ${q(input.sizeBytes)},
${input.current ? 1 : 0}, ${q(input.origin)}, ${q(input.status)}, ${now}, ${now}
)
ON CONFLICT(filename) DO UPDATE SET
url = excluded.url,
bucket = excluded.bucket,
session_id = COALESCE(excluded.session_id, image_assets.session_id),
pack_id = COALESCE(excluded.pack_id, image_assets.pack_id),
target_id = COALESCE(excluded.target_id, image_assets.target_id),
kind = COALESCE(excluded.kind, image_assets.kind),
template_id = COALESCE(excluded.template_id, image_assets.template_id),
title = COALESCE(excluded.title, image_assets.title),
aspect_ratio = COALESCE(excluded.aspect_ratio, image_assets.aspect_ratio),
width = COALESCE(excluded.width, image_assets.width),
height = COALESCE(excluded.height, image_assets.height),
size_bytes = COALESCE(excluded.size_bytes, image_assets.size_bytes),
current = excluded.current,
origin = COALESCE(excluded.origin, image_assets.origin),
status = COALESCE(excluded.status, image_assets.status),
updated_at = excluded.updated_at;
`);
} catch {
// Keep gallery and generation usable even if sqlite is temporarily unavailable.
}
}
export function readJsonRows<T>(sql: string): T[] {
try {
ensureDb();
const output = execFileSync('sqlite3', ['-readonly', '-json', DB_PATH, sql], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
return output ? JSON.parse(output) as T[] : [];
} catch {
return [];
}
}
export function listAuditEvents(sessionId?: string, limit = 200): AuditEventRow[] {
const where = sessionId ? `WHERE session_id = ${q(sessionId)}` : '';
return readJsonRows<AuditEventRow>(`
SELECT id, ts, action, session_id, target_type, target_id, status, provider, message, metadata
FROM audit_events
${where}
ORDER BY ts DESC
LIMIT ${Math.max(1, Math.min(limit, 1000))}
`);
}
export function listImageAssets(sessionId?: string, limit = 500): ImageAssetRow[] {
const where = sessionId ? `WHERE session_id = ${q(sessionId)}` : '';
return readJsonRows<ImageAssetRow>(`
SELECT filename, url, bucket, session_id, pack_id, target_id, kind, template_id, title, aspect_ratio,
width, height, size_bytes, current, origin, status, created_at, updated_at
FROM image_assets
${where}
ORDER BY current DESC, updated_at DESC
LIMIT ${Math.max(1, Math.min(limit, 2000))}
`);
}
export function detectImageDimensions(buf: Buffer, filename: string): { width: number | null; height: number | null } {
if (buf.length >= 24 && buf.toString('ascii', 1, 4) === 'PNG') {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
if (buf.length >= 10 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
const chunk = buf.toString('ascii', 12, 16);
if (chunk === 'VP8X' && buf.length >= 30) {
const width = 1 + buf.readUIntLE(24, 3);
const height = 1 + buf.readUIntLE(27, 3);
return { width, height };
}
}
if (/\.(jpe?g)$/i.test(filename) && buf[0] === 0xff && buf[1] === 0xd8) {
let offset = 2;
while (offset < buf.length) {
if (buf[offset] !== 0xff) break;
const marker = buf[offset + 1];
const length = buf.readUInt16BE(offset + 2);
if (marker >= 0xc0 && marker <= 0xc3 && offset + 8 < buf.length) {
return { height: buf.readUInt16BE(offset + 5), width: buf.readUInt16BE(offset + 7) };
}
offset += 2 + length;
}
}
return { width: null, height: null };
}
export function imageFileInfo(filePath: string): { width: number | null; height: number | null; sizeBytes: number | null } {
try {
if (!existsSync(filePath)) return { width: null, height: null, sizeBytes: null };
const stat = statSync(filePath);
const buf = readFileSync(filePath);
return { ...detectImageDimensions(buf, filePath), sizeBytes: stat.size };
} catch {
return { width: null, height: null, sizeBytes: null };
}
}

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { imageFileInfo, recordEvent, upsertImageAsset } from './auditDb';
import type { ExportManifest, GenSession, UploadedImage, UploadedImageRole } from './types';
const ROOT = path.join(process.cwd(), 'data');
@@ -13,6 +14,15 @@ const ANCHOR_DIR = path.join(ROOT, 'anchors');
const UPLOAD_DIR = path.join(ROOT, 'uploads');
const EXPORT_DIR = path.join(ROOT, 'exports');
const BUCKET_DIRS = {
generated: GEN_DIR,
selected: SEL_DIR,
refs: REF_DIR,
packs: PACK_DIR,
anchors: ANCHOR_DIR,
uploads: UPLOAD_DIR,
} as const;
async function ensureDirs() {
await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, ANCHOR_DIR, UPLOAD_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true })));
}
@@ -20,6 +30,20 @@ async function ensureDirs() {
export async function saveSession(s: GenSession) {
await ensureDirs();
await fs.writeFile(path.join(SESS_DIR, `${s.id}.json`), JSON.stringify(s, null, 2), 'utf-8');
indexSessionImages(s);
recordEvent({
action: 'session.saved',
sessionId: s.id,
targetType: 'session',
targetId: s.id,
status: 'ok',
metadata: {
images: s.images.length,
packs: s.packs?.length ?? 0,
packAssets: (s.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0),
inputMode: s.inputMode ?? 'idea',
},
});
}
export async function loadSession(id: string): Promise<GenSession | null> {
@@ -59,14 +83,131 @@ function safePart(input: string): string {
.slice(0, 60) || 'image';
}
function localFileFromUrl(url: string): { bucket: keyof typeof BUCKET_DIRS; filename: string; filePath: string } | null {
const match = url.match(/^\/api\/img\/(generated|selected|refs|packs|anchors|uploads)\/([^/?#]+)$/);
if (!match) return null;
const bucket = match[1] as keyof typeof BUCKET_DIRS;
const filename = decodeURIComponent(match[2]);
return { bucket, filename, filePath: path.join(BUCKET_DIRS[bucket], filename) };
}
function packKindFromPackId(packId: string): string | null {
return packId.match(/^pack_([^_]+)_/)?.[1] ?? null;
}
function indexSessionImages(session: GenSession) {
for (const image of session.images) {
const local = localFileFromUrl(image.url);
if (!local) continue;
const info = imageFileInfo(local.filePath);
upsertImageAsset({
filename: local.filename,
url: image.url,
bucket: local.bucket,
sessionId: session.id,
targetId: image.id,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: image.status === 'selected' || image.status === 'pending',
origin: local.bucket === 'selected' ? 'selected' : local.bucket === 'uploads' ? 'upload' : 'generated',
status: image.status,
});
}
for (const upload of session.uploadedImages ?? []) {
const local = localFileFromUrl(upload.url);
if (!local) continue;
const info = imageFileInfo(local.filePath);
upsertImageAsset({
filename: local.filename,
url: upload.url,
bucket: local.bucket,
sessionId: session.id,
targetId: upload.id,
title: upload.originalFilename ?? upload.filename,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: true,
origin: 'upload',
status: upload.role,
});
}
if (session.characterSpec?.cleanReferenceImageUrl) {
const local = localFileFromUrl(session.characterSpec.cleanReferenceImageUrl);
if (local) {
const info = imageFileInfo(local.filePath);
upsertImageAsset({
filename: local.filename,
url: session.characterSpec.cleanReferenceImageUrl,
bucket: local.bucket,
sessionId: session.id,
targetId: session.characterSpec.sourceImageId,
title: `${session.characterSpec.name} L1 anchor`,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: true,
origin: 'anchor',
status: 'clean_reference',
});
}
}
for (const pack of session.packs ?? []) {
for (const asset of pack.assets) {
const local = localFileFromUrl(asset.url);
if (!local) continue;
const info = imageFileInfo(local.filePath);
upsertImageAsset({
filename: local.filename,
url: asset.url,
bucket: local.bucket,
sessionId: session.id,
packId: pack.id,
kind: pack.kind,
templateId: asset.templateId,
title: asset.title,
aspectRatio: asset.aspectRatio,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: true,
origin: local.bucket === 'uploads' ? 'upload' : 'pack',
status: asset.status,
});
}
}
}
export async function saveGeneratedImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
await ensureDirs();
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!m) throw new Error('Invalid data URL');
const ext = extFromMime(m[1]);
const file = path.join(GEN_DIR, `${sessionId}_${imageId}.${ext}`);
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
return `/api/img/generated/${sessionId}_${imageId}.${ext}`;
const buffer = Buffer.from(m[2], 'base64');
await fs.writeFile(file, buffer);
const filename = `${sessionId}_${imageId}.${ext}`;
const url = `/api/img/generated/${filename}`;
const info = imageFileInfo(file);
upsertImageAsset({
filename,
url,
bucket: 'generated',
sessionId,
targetId: imageId,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: false,
origin: 'generated',
status: 'saved',
});
recordEvent({ action: 'image.saved', sessionId, targetType: 'image', targetId: imageId, status: 'ok', metadata: { bucket: 'generated', filename, bytes: buffer.length } });
return url;
}
export async function savePackImage(packId: string, assetId: string, dataUrl: string): Promise<string> {
@@ -75,8 +216,27 @@ export async function savePackImage(packId: string, assetId: string, dataUrl: st
if (!m) throw new Error('Invalid data URL');
const ext = extFromMime(m[1]);
const file = path.join(PACK_DIR, `${packId}_${assetId}.${ext}`);
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
return `/api/img/packs/${packId}_${assetId}.${ext}`;
const buffer = Buffer.from(m[2], 'base64');
await fs.writeFile(file, buffer);
const filename = `${packId}_${assetId}.${ext}`;
const url = `/api/img/packs/${filename}`;
const info = imageFileInfo(file);
upsertImageAsset({
filename,
url,
bucket: 'packs',
packId,
kind: packKindFromPackId(packId),
targetId: assetId,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: false,
origin: 'pack',
status: 'saved',
});
recordEvent({ action: 'image.saved', targetType: 'pack_asset', targetId: assetId, status: 'ok', metadata: { bucket: 'packs', packId, filename, bytes: buffer.length } });
return url;
}
export async function saveAnchorImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
@@ -86,8 +246,26 @@ export async function saveAnchorImage(sessionId: string, imageId: string, dataUr
const ext = extFromMime(m[1]);
const safeImageId = imageId.replace(/[^a-zA-Z0-9_-]+/g, '-');
const filename = `${sessionId}_${safeImageId}_clean.${ext}`;
await fs.writeFile(path.join(ANCHOR_DIR, filename), Buffer.from(m[2], 'base64'));
return `/api/img/anchors/${filename}`;
const file = path.join(ANCHOR_DIR, filename);
const buffer = Buffer.from(m[2], 'base64');
await fs.writeFile(file, buffer);
const url = `/api/img/anchors/${filename}`;
const info = imageFileInfo(file);
upsertImageAsset({
filename,
url,
bucket: 'anchors',
sessionId,
targetId: imageId,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: true,
origin: 'anchor',
status: 'clean_reference',
});
recordEvent({ action: 'image.saved', sessionId, targetType: 'anchor', targetId: imageId, status: 'ok', metadata: { bucket: 'anchors', filename, bytes: buffer.length } });
return url;
}
export async function saveUploadedImage(opts: {
@@ -104,8 +282,9 @@ export async function saveUploadedImage(opts: {
const ext = extFromMime(opts.mimeType);
const baseName = opts.originalFilename ? safePart(path.parse(opts.originalFilename).name) : 'upload';
const filename = `${id}_${baseName}.${ext}`;
await fs.writeFile(path.join(UPLOAD_DIR, filename), opts.buffer);
return {
const file = path.join(UPLOAD_DIR, filename);
await fs.writeFile(file, opts.buffer);
const uploaded = {
id,
url: `/api/img/uploads/${filename}`,
filename,
@@ -116,6 +295,22 @@ export async function saveUploadedImage(opts: {
accessoryName: opts.accessoryName,
needsCleanup: opts.needsCleanup ?? true,
};
const info = imageFileInfo(file);
upsertImageAsset({
filename,
url: uploaded.url,
bucket: 'uploads',
targetId: id,
title: uploaded.originalFilename ?? filename,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: true,
origin: 'upload',
status: opts.role,
});
recordEvent({ action: 'upload.saved', targetType: 'upload', targetId: id, status: 'ok', metadata: { filename, originalFilename: opts.originalFilename, role: opts.role, bytes: opts.buffer.length } });
return uploaded;
}
export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise<string> {
@@ -127,7 +322,23 @@ export async function copyToSelected(sessionId: string, imageId: string, srcUrl:
const src = path.join(GEN_DIR, filename);
const dst = path.join(SEL_DIR, filename);
await fs.copyFile(src, dst);
return `/api/img/selected/${filename}`;
const url = `/api/img/selected/${filename}`;
const info = imageFileInfo(dst);
upsertImageAsset({
filename,
url,
bucket: 'selected',
sessionId,
targetId: imageId,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: true,
origin: 'selected',
status: 'selected',
});
recordEvent({ action: 'image.selected_copy', sessionId, targetType: 'image', targetId: imageId, status: 'ok', metadata: { filename } });
return url;
}
export type ImageBucket = 'generated' | 'selected' | 'refs' | 'packs' | 'anchors' | 'uploads';
@@ -187,13 +398,39 @@ export async function saveRefImage(sessionId: string, idx: number, dataUrl: stri
if (!m) throw new Error('Invalid ref data URL');
const ext = extFromMime(m[1]);
const file = path.join(REF_DIR, `${sessionId}_ref${idx}.${ext}`);
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
return `/api/img/refs/${sessionId}_ref${idx}.${ext}`;
const buffer = Buffer.from(m[2], 'base64');
await fs.writeFile(file, buffer);
const filename = `${sessionId}_ref${idx}.${ext}`;
const url = `/api/img/refs/${filename}`;
const info = imageFileInfo(file);
upsertImageAsset({
filename,
url,
bucket: 'refs',
sessionId,
targetId: `ref${idx}`,
width: info.width,
height: info.height,
sizeBytes: info.sizeBytes,
current: true,
origin: 'ref',
status: 'reference',
});
recordEvent({ action: 'image.saved', sessionId, targetType: 'ref', targetId: `ref${idx}`, status: 'ok', metadata: { bucket: 'refs', filename, bytes: buffer.length } });
return url;
}
export async function saveExportManifest(manifest: ExportManifest): Promise<string> {
await ensureDirs();
const filename = `${manifest.sessionId}_${manifest.packKind}_${manifest.version}_manifest.json`;
await fs.writeFile(path.join(EXPORT_DIR, filename), JSON.stringify(manifest, null, 2), 'utf-8');
recordEvent({
action: 'export.manifest_saved',
sessionId: manifest.sessionId,
targetType: 'manifest',
targetId: manifest.id,
status: 'ok',
metadata: { filename, packId: manifest.packId, packKind: manifest.packKind, files: manifest.files.length },
});
return `/api/export/${filename}`;
}

View File

@@ -267,6 +267,7 @@ export type LockCharacterFromUploadRequest = {
export type RegenerateAssetRequest = {
sessionId: string;
userRefinement?: string;
confirmCost?: boolean;
};
export type RegenerateAssetResponse = {