尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗A daily AI murder mystery. Everyone gets the same case each day. Read the file, study the evidence, interrogate four AI-played suspects (20 questions in total), then make one accusation.
▶ Play it: dailyalibi.vercel.app · English + Turkish · no sign-up
Built with Next.js 16 (App Router), TypeScript, Supabase (Postgres + Row Level Security + anonymous auth), Gemini's free tier, Zod, next-intl and Tailwind. It runs on Vercel Hobby and Supabase Free and costs $0 a month.
A chatbot that plays a murder suspect is easy to build. A game built on one has three harder problems:
The sections below explain how each one is handled.
flowchart LR
subgraph Browser
UI["Case file · Evidence<br/>Interrogation · Notebook"]
end
subgraph Vercel["Next.js on Vercel (server)"]
RSC["Server Components<br/>(public briefing only)"]
API["Route Handlers<br/>/api/interrogate · /api/hint · /api/accuse"]
PROMPT["Suspect prompt builder<br/>(one suspect's notes only)"]
GUARD["Output guard<br/>(streamed, 40-char holdback)"]
CRON["Vercel Cron<br/>daily publish"]
ADMIN["Admin panel<br/>(Server Actions)"]
end
subgraph Supabase
DB[("Postgres + RLS<br/>cases.secret: no browser grant")]
AUTH["Anonymous auth<br/>+ Turnstile"]
end
subgraph Offline["Offline (GitHub Actions / CLI)"]
GEN["Generator → Zod → 14 logic checks<br/>→ blind solver → repair"]
end
LLM["Gemini<br/>(free tier, behind a provider-agnostic module)"]
UI -- "public data" --> RSC
UI -- "question (≤ length limit)" --> API
API -- "counter, rate limit (SQL functions)" --> DB
API --> PROMPT --> LLM --> GUARD -- "streamed answer" --> UI
UI <-- "own rows only (RLS)" --> DB
UI --> AUTH
CRON --> DB
ADMIN -- "approve · schedule" --> DB
ADMIN -. "dispatch" .-> GEN
GEN --> LLM
GEN -- "draft + review" --> DB
One game, step by step: the case page is a Server Component that reads only the public briefing (cached). Each question is a POST to a Route Handler. The server checks the session and rate limits, reserves a question in Postgres (the counter lives in a SQL function, not in the browser), loads the case itself, builds the prompt for that one suspect, and streams the answer back through the output guard. The accusation is checked and scored on the server. Only after that is the solution sent to this player.
The rule: the killer, the motive, the true timeline and every suspect's secrets never reach the browser, in any form.
src/lib/case/schema.ts) has a public part and a secret part. Every module that touches secret imports server-only, so importing it from a Client Component breaks the build.cases.secret has no column grant for the anon/authenticated roles. A player who copies the publishable key and queries Supabase directly gets permission denied. Players can read their own games and messages, but can only write them through SQL functions (start_game, reserve_question, finish_game, …) that only the server's key may execute. Every table has RLS enabled.start_game function and every server read require it. Without this, a case scheduled for next week could be played early.src/server/interrogation/prompt.ts) takes the public briefing, one suspect's private notes and an isKiller boolean. The solution and the other suspects' notes are not parameters, so they cannot end up in the prompt. An innocent suspect does not know who the killer is, so no jailbreak can get it out of them.npm run check:leaks builds the app and searches every client JS file and pre-rendered HTML/RSC payload for every secret string of every case (115 at the last live check). Result: 0 leaks.npm run check:prompts builds every suspect's real prompt and fails if it contains another suspect's secret or (for innocents) the solution.npm run smoke plays a full game as a real user, then tries to cheat with the same credentials: reading secret, resetting the counter, calling the RPCs directly, reading another player's notes.revealWhen, such as "when shown the telephone log". The rule is that a condition beats a lie, and a condition that lists several things needs all of them. With the Show evidence button, a player can put a piece of evidence in front of a suspect. The evidence text comes from the case, never from the player.answer-guard.ts, guarded-stream.ts) holds back the last 40 characters. When an answer breaks character, echoes the prompt or (for the killer) confesses, it stops the model and replaces the unsent part with an in-character line. What is saved is exactly what the player saw.| Eval | What it does | Result |
|---|---|---|
Prompt injection (npm run eval) | 50 attacks (40 single-turn + 10 multi-turn, 10 categories, EN + TR) against every suspect role in 4 cases, using the game's real prompts | 199/200 defended (model alone 198/200). The one failure was a false alarm on manual review. Right language: 200/200 |
Consistency (npm run eval:consistency) | 6-question interviews per suspect, EN + TR, including evidence confrontations, graded by a judge model against the private notes | 192 answers: consistent 99.5%, no invented facts 100%, no early reveals 98.4%, reveals on cue 99.5%, in character 99.5% |
The evals found real bugs that were then fixed. A killer gave up their motive after seeing one piece of evidence when two were required. An internal [Reply in Turkish.] instruction leaked into an answer. A Turkish pattern in the guard was too strict.
LLM-written mysteries fail in two ways: they contradict themselves, or they can be solved from the case file alone, without any interrogation. The pipeline (src/server/generator/) checks both:
npm run check:solver checks that the solver's input really is blind.Generation with a thinking model takes 3-12 minutes, longer than Vercel's 300-second function limit. The admin panel therefore starts a GitHub Actions workflow and shows its recent runs.
ChatModel interface (src/server/ai/), so the model is set in one place. If the main model hits its quota, errors, or sends nothing for 10 seconds, the same request goes to a fallback model.fetch is enough. Gemini (SSE streaming) and Turnstile are called directly. The app has 8 runtime dependencies.cp .env.example .env.local # fill in Supabase + Gemini keys (see comments in the file)
npm install
# apply supabase/migrations/*.sql to your Supabase project, then:
npm run db:seed # loads the hand-written sample case
npm run dev
Without GEMINI_API_KEY, development uses a mock model.
| Command | Purpose |
|---|---|
npm run check:leaks | Search the production build for secret case text |
npm run check:prompts / check:solver | Verify prompt and solver isolation |
npm run check:guard | Output-guard unit checks |
npm run smoke | Full game + cheating attempts against a running app |
npm run eval / eval:consistency | AI evals (use GEMINI_OFFLINE_API_KEY) |
npm run generate -- --difficulty=hard | Generate and check a new case |
src/app/[locale]/ pages (EN/TR), incl. case, interrogate, accuse, result, archive, lab, admin
src/app/api/ route handlers: interrogate, hint, accuse, game, player, cron
src/lib/case/ Zod case schema (public / secret split)
src/server/ai/ provider-agnostic model interface, Gemini, fallback, retry
src/server/interrogation/ suspect prompt, output guard, game store (Supabase RPCs)
src/server/generator/ case generator, logic checks, blind solver
supabase/migrations/ schema, RLS, game functions, rate limits
scripts/ leak/isolation checks, smoke test, evals, generator CLI