尚未检测到更新。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