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