diff --git a/AGENTS.md b/AGENTS.md index 982be77..6aad5cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,21 @@ COS_APPID= # Memory Context -# $CMEM MemeStudio 2026-05-01 9:31am GMT+8 +# [MemeStudio] recent context, 2026-05-12 10:21am GMT+8 -No previous sessions found. +Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note +Format: ID TIME TYPE TITLE +Fetch details: get_observations([IDs]) | Search: mem-search skill + +Stats: 6 obs (1,857t read) | 145,103t work | 99% savings + +### May 12, 2026 +1517 10:14a 🟣 Batch import logic upgraded to upsert +1518 " 🔵 MemeStudio project context loaded +1519 10:15a 🔵 Level schema lacks riddle_id for upsert deduplication +1520 " 🔵 Batch import always POSTs, never checks for existing riddle_id +1521 10:16a 🔵 API route patch failed - upsert logic not applied +1522 10:17a ✅ Schema, types, and UI updated for riddle_id upsert + +Access 145k tokens of past work via get_observations([IDs]) or mem-search skill. \ No newline at end of file diff --git a/app/api/levels/route.ts b/app/api/levels/route.ts index f81c446..6f291b1 100644 --- a/app/api/levels/route.ts +++ b/app/api/levels/route.ts @@ -56,6 +56,38 @@ function parsePosition(raw: unknown): number | undefined { return n } +function parseRiddleId(raw: unknown): string | null { + if (raw === undefined || raw === null || raw === '') return null + if (typeof raw !== 'string') { + throw new Error('riddleId 必须是字符串') + } + + const riddleId = raw.trim() + if (!riddleId) return null + if (riddleId.length > 128) { + throw new Error('riddleId 长度不能超过 128') + } + return riddleId +} + +function optionalString(raw: unknown): string | null { + return raw ? String(raw) : null +} + +function buildLevelData(body: Record) { + return { + image1Url: body.image1Url as string, + image1Description: optionalString(body.image1Description), + image2Url: body.image2Url as string, + image2Description: optionalString(body.image2Description), + answer: body.answer as string, + punchline: optionalString(body.punchline), + hint1: optionalString(body.hint1), + hint2: optionalString(body.hint2), + hint3: optionalString(body.hint3), + } +} + // POST /api/levels - Create a new level // body.position 可选:1..N+1;不传则追加末尾 export async function POST(request: NextRequest) { @@ -69,17 +101,7 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { - image1Url, - image1Description, - image2Url, - image2Description, - answer, - punchline, - hint1, - hint2, - hint3, - } = body + const { image1Url, image2Url, answer } = body if (!image1Url || !image2Url || !answer) { return NextResponse.json( @@ -89,15 +111,49 @@ export async function POST(request: NextRequest) { } let position: number | undefined + let riddleId: string | null try { position = parsePosition(body.position) + riddleId = parseRiddleId(body.riddleId) } catch (e) { return NextResponse.json( - { error: e instanceof Error ? e.message : 'invalid position' }, + { error: e instanceof Error ? e.message : 'invalid level payload' }, { status: 400 } ) } + const data = buildLevelData(body) + + if (riddleId) { + const existing = await prisma.level.findUnique({ + where: { riddleId }, + }) + + if (existing) { + let newSortKey: string | undefined + if (position !== undefined) { + const total = await prisma.level.count() + if (position > total) { + return NextResponse.json( + { error: `position 超出范围,合法区间 [1, ${total}]` }, + { status: 400 } + ) + } + newSortKey = await keyForPosition(position, existing.id) + } + + const level = await prisma.level.update({ + where: { id: existing.id }, + data: { + ...data, + ...(newSortKey ? { sortKey: newSortKey } : {}), + }, + }) + + return NextResponse.json(level) + } + } + let sortKey: string if (position === undefined) { sortKey = await keyAfterLast() @@ -115,15 +171,8 @@ export async function POST(request: NextRequest) { const level = await prisma.level.create({ data: { id: uuidv4(), - image1Url, - image1Description: image1Description || null, - image2Url, - image2Description: image2Description || null, - answer, - punchline: punchline || null, - hint1: hint1 || null, - hint2: hint2 || null, - hint3: hint3 || null, + riddleId, + ...data, sortKey, }, }) @@ -151,18 +200,7 @@ export async function PUT(request: NextRequest) { } const body = await request.json() - const { - id, - image1Url, - image1Description, - image2Url, - image2Description, - answer, - punchline, - hint1, - hint2, - hint3, - } = body + const { id } = body if (!id) { return NextResponse.json({ error: 'id is required' }, { status: 400 }) @@ -191,18 +229,12 @@ export async function PUT(request: NextRequest) { newSortKey = await keyForPosition(position, id) } + const data = buildLevelData(body) + const level = await prisma.level.update({ where: { id }, data: { - image1Url, - image1Description: image1Description || null, - image2Url, - image2Description: image2Description || null, - answer, - punchline: punchline || null, - hint1: hint1 || null, - hint2: hint2 || null, - hint3: hint3 || null, + ...data, ...(newSortKey ? { sortKey: newSortKey } : {}), }, }) diff --git a/components/levels/batch-import-dialog.tsx b/components/levels/batch-import-dialog.tsx index cb23117..448dd29 100644 --- a/components/levels/batch-import-dialog.tsx +++ b/components/levels/batch-import-dialog.tsx @@ -29,6 +29,7 @@ type ItemStatus = 'pending' | 'uploading' | 'creating' | 'done' | 'error' interface ParsedItem { id: string // riddle_id 或文件夹名,仅用于 React key + riddleId?: string folderName: string // 本地文件引用 referenceFile?: File @@ -114,7 +115,11 @@ function buildFilePathMap(files: FileList): Map { } function validateItem(item: ParsedItem) { - if (!item.answer) { + if (item.riddleId !== undefined && !item.riddleId) { + item.parseError = '未解析到 riddle_id' + } else if (item.riddleId && item.riddleId.length > 128) { + item.parseError = 'riddle_id 长度不能超过 128' + } else if (!item.answer) { item.parseError = '未解析到 answer' } else if (item.answer.length > ANSWER_MAX_LENGTH) { item.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${item.answer.length})` @@ -123,6 +128,20 @@ function validateItem(item: ParsedItem) { } } +function markDuplicateRiddleIds(items: ParsedItem[]) { + const counts = new Map() + items.forEach((item) => { + if (!item.riddleId) return + counts.set(item.riddleId, (counts.get(item.riddleId) || 0) + 1) + }) + + items.forEach((item) => { + if (item.riddleId && counts.get(item.riddleId)! > 1) { + item.parseError = `riddle_id 重复: ${item.riddleId}` + } + }) +} + async function parseConfigJson( jsonFile: FileWithPath, fileByPath: Map @@ -139,16 +158,18 @@ async function parseConfigJson( const config = raw as RiddleConfigItem if (!config || typeof config !== 'object') return + const riddleId = (config.riddle_id || '').trim() const referencePath = config.reference_image || '' const riddlePath = config.riddle_image || '' const reference = fileByPath.get(joinPath(jsonDir, referencePath)) || fileByPath.get(normalizePath(referencePath)) const riddle = fileByPath.get(joinPath(jsonDir, riddlePath)) || fileByPath.get(normalizePath(riddlePath)) - const id = config.riddle_id || `${jsonFile.name}-${index + 1}` - const folderName = config.riddle_id || referencePath.split('/').slice(-2, -1)[0] || `第 ${index + 1} 关` + const id = `${riddleId || jsonFile.name}-${index + 1}` + const folderName = riddleId || referencePath.split('/').slice(-2, -1)[0] || `第 ${index + 1} 关` const hints = Array.isArray(config.hints) ? config.hints : [] const item: ParsedItem = { id, + riddleId, folderName, referenceFile: reference, riddleFile: riddle, @@ -167,6 +188,8 @@ async function parseConfigJson( items.push(item) }) + markDuplicateRiddleIds(items) + return items } @@ -234,6 +257,7 @@ async function parseFolders(files: FileList): Promise { const text = await metadata.text() const json = JSON.parse(text) as RiddleMetadata const plan = json.plan || {} + item.riddleId = (plan.riddle_id || '').trim() || undefined item.anchorText = truncate(plan.anchor_text, 500) item.answer = (plan.answer || '').trim() item.punchline = ( @@ -375,9 +399,10 @@ export function BatchImportDialog({ uploadToCos(it.riddleFile, it.riddleFile.name), ]) - updateItem(it.id, { status: 'creating', statusMessage: '创建关卡…' }) + updateItem(it.id, { status: 'creating', statusMessage: '导入关卡…' }) const payload = { + ...(it.riddleId ? { riddleId: it.riddleId } : {}), image1Url, image1Description: it.anchorText || '', image2Url, @@ -398,7 +423,7 @@ export function BatchImportDialog({ const err = await res.json().catch(() => ({})) throw new Error(err.error || `HTTP ${res.status}`) } - updateItem(it.id, { status: 'done', statusMessage: '已创建' }) + updateItem(it.id, { status: 'done', statusMessage: '已导入' }) } catch (e) { updateItem(it.id, { status: 'error', @@ -498,10 +523,10 @@ export function BatchImportDialog({ {isRunning ? ( <> - 创建中… + 导入中… ) : ( - `确认创建 (${items.filter((i) => i.status !== 'done' && !i.parseError).length})` + `确认导入 (${items.filter((i) => i.status !== 'done' && !i.parseError).length})` )} @@ -531,7 +556,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) case 'done': return ( - 已创建 + {item.statusMessage || '已导入'} ) case 'error': @@ -549,7 +574,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) ) } return ( - 待创建 + 待导入 ) } })() diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60b1d44..52387da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ datasource db { model Level { id String @id @default(uuid()) + riddleId String? @unique @map("riddle_id") @db.VarChar(128) image1Url String @map("image1_url") @db.VarChar(500) image1Description String? @map("image1_description") @db.VarChar(500) image2Url String @map("image2_url") @db.VarChar(500) diff --git a/types/index.ts b/types/index.ts index 74636c0..61d929f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,5 +1,6 @@ export interface Level { id: string + riddleId: string | null image1Url: string image1Description: string | null image2Url: string @@ -16,6 +17,7 @@ export interface Level { } export interface LevelFormData { + riddleId?: string image1Url: string image1Description?: string image2Url: string