The CLA bot run on PR #99 failed with 'Resource not accessible by
integration' because it tried to commit cla-signatures/version-1.json
to the branch-protection-protected main branch. The CLA Assistant
action cannot push to protected branches (contributor-assistant/
github-action#150); PAT identity is not granted bypass even though
the PAT owner is repo admin.
Fix: store signatures on a dedicated orphan branch 'cla-signatures'
that has no protection rules, so the PAT can commit freely. This is
the workaround recommended by the action maintainers and the dominant
community solution.
Also brings the staging copy of cla.yml up to parity with main (the
guard step + SHA pin from PR #93 had not been back-merged to staging):
- Add the 'fail on missing CLA_BOT_TOKEN' guard step
- Pin action to contributor-assistant/github-action@ca4a40a7... (v2.6.1);
the old 'cla-assistant-action' path 404s and was a second reason the
bot never fired
Rewrites the i18n system introduced in PR #94 to use Next.js App Router
[locale] dynamic segments with SSR-rendered translations and proper
middleware locale routing.
- Add middleware locale detection: / rewrites to /zh-CN/ internally,
/en and /ja pass through, /zh-CN/... redirects to bare path
- Move all 7 pages under app/[locale]/ with SSR translation injection
- Fix server→client serialization: pre-evaluate function-valued
translations (makeSerializable) to eliminate hydration flash
- Fix language switch key flash: use hard navigation with localStorage-
only persistence, avoiding React state update before page reload
- Add <link rel="alternate" hreflang> tags for multilingual SEO
- Fix Supabase setAll overwriting locale rewrite response
- Trim locales from 22 to 3 (zh-CN/en/ja), delete 19 incomplete files
- LLM-translate 240 firstact game preset JSONs (en + ja, landscape +
portrait) and story titles via gemini-3.5-flash
- Delete 11 one-off migration scripts and outdated i18n docs
- Add useLocalePath hook and navigation utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reject header-only files (37 bytes, empty plaintext) at the unpack
boundary instead of letting them through as empty strings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The encrypted .infiplot format (AES-256-GCM via GALLERY_SECRET) provided no
meaningful security — the payload is AI-generated story content with no
credentials or PII, and the project is open source. Replace with plaintext +
SHA-256 integrity check (format v2). Story share is now always enabled without
requiring a server secret.
- galleryCrypto.ts: AES-256-GCM → plaintext + SHA-256 hash; remove secret param
- 4 API routes: remove GALLERY_SECRET guard and 503 fallback
- story-unpack: forward specific error messages (v1 compat, hash mismatch)
- gallery/page.tsx: remove stale AES-GCM comment
- AGENTS.md: document gallery-pack/gallery-unpack routes
- .env.example, wrangler.jsonc: remove GALLERY_SECRET references
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add "keep_vars": true so Dashboard-set variables survive redeployments
(Vercel-like behavior). Remove empty-string placeholder vars — provider
config (TEXT_BASE_URL, IMAGE_*, VISION_*, TTS_*) is now managed entirely
via Dashboard. Update comments to document three variable categories
(secrets, runtime, build-time) and their setup locations.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Replace hardcoded API URLs and model names with empty-string placeholders
so self-deployers set their own values via Dashboard
- Comment out D1/R2/KV bindings and placement region to prevent deploy
failure for users who don't need these optional services
- Add comprehensive comments for secrets, optional tuning vars, and
build-time NEXT_PUBLIC_* variables
- Hide /stories navigation link until auth integration is ready
(all story persistence code is retained)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into
staging with conflict resolution, feature integration, and bug fixes.
Engine:
- Paradigm D: single-stream Writer replacing dual-phase Plan/Beats
- Delete Architect agent; story bible generated via Writer <plan> tag
- Modular prompt architecture (segments/registry/builder)
- StreamRouter for tagged stream splitting (<plan>/<story>/<choices>)
Infrastructure:
- Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter)
- D1 database schema + Drizzle ORM (scaffolded, not yet active)
- R2 storage helpers (scaffolded, not yet active)
- Story persistence API routes + client-side persistence
BYOK (Bring Your Own Key):
- /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth)
- CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to
server proxy transparently via OpenAI SDK custom fetch
- BYO config support added to classify-freeform and vision routes
- SettingsModal CORS privacy notice (keys never logged/stored)
SSE streaming:
- engineClient.ts: fetchSSE helper for progressive scene events
- startSession/requestScene accept optional emit callback
- Fix SSE error event field name (error → message) in scene/start routes
i18n integration:
- Wire buildLanguageDirective into paradigm D's prompt builder
- Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text
- Preserve Session.language + LanguageSwitcher from i18n commit
Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- New client-side i18n via React Context (useI18n, tArray, I18nProvider)
- Catalog ships 21 locale stubs; only zh-CN/en/ja have reviewed translations
- Header language switcher (globe icon + short label) before settings gear
- All hardcoded Chinese UI text migrated to keys: typewriter, options,
hints (with embedded gear icon via dangerouslySetInnerHTML), settings
panel, footer/about, play page hints
- AI output language follows user-selected locale via trailing one-liner
directive appended to Architect/Writer/CharacterDesigner/InsertBeat
user messages (preserves system-prompt cacheability)
- Per-locale separator rule: zh uses middot between every glyph; en/ja
use plain spaces
- Option value → i18n key suffix maps preserve Chinese as the underlying
identifier so analytics unions and STYLE_MAP keys stay byte-stable
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Next.js 16 locks proxy.ts to the Node.js runtime, but OpenNext for
Cloudflare rejects Node.js middleware at build time ("Node.js middleware
is not currently supported", build.js exit 1). Rename to middleware.ts
with an explicit experimental-edge runtime so the Supabase SSR cookie
refresh runs on edge and stays deployable to both Vercel and Workers.
Supabase SSR only uses Web APIs (fetch, cookies), so it is
edge-compatible; the core getUser() refresh logic is unchanged. The
matcher excludes static assets by file extension (not by "contains a
dot") so future dotted dynamic routes (e.g. /u/john.doe) still get the
cookie refresh. getUser() is wrapped in try/catch so a transient
network error (rethrown by @supabase/auth-js) doesn't 500 or crash the
page request — the cookie simply isn't refreshed that round.
Note: runtime must be "experimental-edge", not "edge". Next.js 16 routes
the root middleware file through the pages-router static-info path, where
runtime "edge" throws E1015 at build ("Use runtime 'experimental-edge'
instead"). "experimental-edge" only warns; both are treated as edge by
isEdgeRuntime().
Verified: pnpm typecheck, pnpm build (Vercel), pnpm build:cf
(Cloudflare — Bundling middleware function -> OpenNext build complete,
node-middleware guard no longer fires).
- Add "X" to GENDERS array in lib/options.ts
- Add example phrases for "X" gender (sci-fi themed)
- Make "X" use same preset cards as male gender
- Map "X" to "通用性别" when transmitting to AI
- Add "X" to DISPLAY_ORDER (same as male)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address the valid points from pr-agent on PR #89, skip the inaccurate
ones (e.g. the $contributorName placeholder it suggested does not exist
in cla-assistant-action; create-file-commit-message fires before any
signer exists).
- cla.yml: move 'token configured?' check into job env and put the whole
step-level if inside a single ${{ }} so && / || are evaluated as a
boolean (step-level if cannot safely reference secrets.* directly)
- cla.yml: declare minimal explicit permissions (contents/pull-requests/
issues/statuses) — this workflow runs on pull_request_target with a token
- cla.yml: drop the overly broad '*bot' allowlist wildcard; keep explicit
bot + maintainer accounts only
- cla.yml: clean up the stray trailing '@' in create-file-commit-message
(used once, at signature-store creation, before any signer exists)
- README{,.en,.ja}: clarify that the CLA is signed via a PR comment, not
before opening the PR — matches the actual CONTRIBUTING flow
- Model support: text/vision now OpenAI-compatible only (Claude/Gemini
via their OpenAI-compatible endpoints); drop stale native-protocol copy
and the anthropic/google rows from the *_PROVIDER table
- TTS: document StepFun (step-tts-2, paid, better quality) alongside
Xiaomi MiMo (free) across zh/en/ja; update Vercel deploy envDescription
- Claude: note that direct Anthropic endpoints lack caching, recommend
gateway for full experience
- Cloudflare: preserve existing one-click deploy (compat work in progress)
Introduce a Contributor License Agreement (CLA) so external contributions
can be licensed under AGPL-3.0 and any other terms (incl. closed-source),
keeping the AGPL-3.0 codebase usable in closed-source projects.
- CLA.md: authoritative English CLA (ICLA + employer authorization, v1.0)
- CLA.zh.md: non-binding Chinese reference translation
- CONTRIBUTING.md: bilingual contributing guide, points to CLA
- .github/workflows/cla.yml: self-hosted cla-assistant-action that records
signatures into cla-signatures/version-1.json; exempts maintainers & bots
via allowlist; skips when CLA_BOT_TOKEN is unset
- .github/PULL_REQUEST_TEMPLATE.md: guides contributors to sign
- README.{md,en.md,ja.md}: add License & contributing footer
- app/terms: note CLA requirement in the IP section
Enforcement requires repo-level setup (PAT secret + branch protection)
documented in cla.yml; not covered by this commit.
WEBP produces ~90% smaller files than PNG at visually identical quality
(tested: 5.4MB → ~550KB per 1792×1024 image), significantly reducing
client download time for users on slower connections.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- StepFun voice selection: CharacterDesigner picks a preset voiceId from the
32-entry catalog (zero extra LLM call); pickStepfunVoiceId remains as fallback.
- Prebaked homepage cards enriched with stepfunVoiceId (147 characters, gemini model).
- /api/tts-provider endpoint + client probe: skip the ~220KB Xiaomi reference
audio when the server runs StepFun (saves Fast Origin Transfer bandwidth).
- Server-side resolveVoice normalization: re-provisions on provider mismatch.
- Removed hardcoded 1.2x speech playback speed (was for slow MiMo voice).
- Hardened voice-provider validation per PR-agent review.
Xiaomi path prompt is byte-identical to history (prompt-cache-preserving).
Address PR-agent review findings:
- resolveVoice fast path: replace ambiguous boolean comparison
(voiceProvider === "stepfun") === serverStepfun with explicit
per-provider equality checks. Prevents an undefined or unknown
provider from matching the non-stepfun (xiaomi) branch by accident.
- /api/beat-audio route: reject requests whose voice.provider is present
but not in the VALID_TTS_PROVIDERS whitelist (e.g. "azure"). Previously
such a request would pass validation when fallback fields were also
present, and resolveVoice might use the invalid voice directly instead
of falling back to reprovision — producing a silent beat instead of a
voiced one.
Address two suggestions from the PR agent review:
1. lib/authResume.ts — catch isAuthed() exceptions in
consumeResumeSnapshot. The network/timeout path now returns
null (snapshot already removed earlier to prevent the play-page
bootstrap's retryBootstrap loop from re-entering this path).
Document the intentional removeItem-before-isAuthed ordering.
2. components/AuthModal.tsx — wrap onBeforeOAuth in try-catch so
a snapshot failure (e.g. sessionStorage blocked in privacy mode)
does not abort the OAuth flow and leave the UI stuck in loading.
Re-ran scripts/enrich-firstacts-stepfun.mjs with gemini-3.1-flash-lite-preview
as the TEXT model (was deepseek-v4-flash). The new picks better match the
mysterious / cool / melancholic archetypes common in the curated cards
(e.g. 夜煌 清冷空灵+悲怆决绝 → lengyanyujie 冷艳御姐, was youyanvsheng).
Only stepfunVoiceId values changed across 52 cards; voice (Xiaomi) /
imageUrl / scene untouched. 0 failures across the 147-character run.
run: pnpm enrich:firstacts [--force] [--portrait]
The SPEECH_RATE=1.2 constant was added to speed up the somewhat slow MiMo
voicedesign voice. With StepFun preset voices (whose tempo is already
appropriate) and no per-provider logic, a global 1.2x is no longer the
right default. Remove the constant and all 4 of its uses:
- the constant declaration + comment
- two `el.playbackRate = SPEECH_RATE` assignments (audio now plays at 1.0)
- the typewriter pacing divisor (`/ SPEECH_RATE`) — audio and text both
return to original duration, staying in lockstep
A future user-facing speech-speed setting (UI control + persisted pref)
would be a separate feature with a different shape; no placeholder kept.
Critical: play-page bootstrap infinite loop when AUTH_ENABLED and no
resume snapshot. The refactor changed the gate from
`if (AUTH_ENABLED && hasSnapshot)` to `if (AUTH_ENABLED)`, so any
snapshot-less /play entry (the common case — normal card/preset/custom
start) entered the async branch, got null from consumeResumeSnapshot,
bumped retryBootstrap, and re-ran the effect forever. Restored the
peek-before-await: only enter the async resume branch when a snapshot
actually exists; otherwise fall straight through to normal bootstrap.
Verified via control-flow simulation across all three paths (no
snapshot / snapshot + signed in / snapshot + not signed in).
Major: homepage auto-started a game after a bare OAuth login. Routing
persistPendingStart through AuthModal.onBeforeOAuth fired it for every
OAuth redirect, including bare logins via UserChip / StyleModal
onRequireAuth (where pendingAction is null and the user only wanted to
sign in). Guarded the snapshot on `pendingAction === "start"` so only
the mid-start flow persists; bare logins no longer resurrect the form
and auto-start on return.
Extract the page-agnostic resume primitives into lib/authResume.ts:
- isAuthed() — single login check (was duplicated in app/page.tsx)
- writeResumeSnapshot(key, primary, fallbacks) — quota-safe sessionStorage
write with ordered lighter-payload fallbacks (was hand-rolledTry/catch
in both pages)
- consumeResumeSnapshot<T>(key) — consume-once resume gate that verifies
the user is signed in before returning the snapshot, else clears it
Both pages now share this plumbing while keeping their own snapshot shapes
and restore side effects (home: form fields + start(); play: Session +
restorePlayResume + deferred action replay).
Unify the persist trigger: home previously snapshotted eagerly inside
start() before opening the modal, while play snapshotted in
AuthModal.onBeforeOAuth at redirect time. Move home to the same
onBeforeOAuth trigger so both pages persist at the single OAuth-redirect
instant — the eager-snapshot special case is gone, and OTP (no redirect)
keeps its in-place onSuccess resume on both pages.
Net: -21 lines. Behavior preserved for OTP; OAuth resume now consistent.
Google/GitHub OAuth is a full-page round-trip that unmounts the app and
destroys the in-memory Session (the server is stateless). Returning to
/play?card=m0 re-bootstrapped from the first-act JSON, restarting the
story from scene 1 — the user lost all progress. OTP login kept state
in-memory (no redirect) and was unaffected.
Mirror the homepage 89a5c54 OAuth state-loss fix: snapshot the exact
scene/beat/visited-beats/orientation/image into sessionStorage just
before the redirect, then restore it on mount after the round-trip
(verified signed in). Re-resolve the remote image URL to a fresh blob
(blob: URLs are revoked on unmount). The pending action that hit the
401 (choice / freeform / background-click) is replayed once the restored
state commits, so the player lands exactly where they were headed.
Quota fallback drops the user-uploaded style-reference image (~100KB)
and retries; voices are kept (continuity over rare quota miss). Failure
to restore (corrupt snapshot / not signed in) relinquishes the bootstrap
slot and falls back to normal card/preset/custom start instead of a
blank loading screen.
AuthModal gains an optional onBeforeOAuth callback fired synchronously
before signInWithOAuth navigates away (sessionStorage.setItem is sync).
Two follow-ups from pr-agent review of #79:
1. director.ts voicePromises built a Character WITHOUT stepfunVoiceId, so
on a StepFun server the client (which omits the voice payload to save
FOT) echoed back only voiceDescription — and the server re-scored via
pickStepfunVoiceId every beat instead of honoring the LLM pick. The
whole "CharacterDesigner picks a preset id" mechanism was effectively
bypassed on live StepFun sessions (it only worked for prebaked cards,
which carry stepfunVoiceId in their JSON). Persist stepfunVoiceId onto
the Character so the client→server round-trip keeps the LLM selection.
2. fetchBeatAudio's null-provider branch (probe pending) required
speaker.voice and silently dropped a stepfun-only speaker. Accept any
synthesizable source (voice | stepfunVoiceId | voiceDescription) so a
slow getTtsProvider probe can't drop audio during the first scene's
fetch window. The server resolveVoice normalizes regardless of which
fields arrive.
Add characters[i].stepfunVoiceId to all 106 prebaked homepage first-act
JSONs (firstact/ + firstact-portrait/) so cards produce sound when the
server runs StepFun. Generated by scripts/enrich-firstacts-stepfun.mjs —
one TEXT-provider LLM call per character picking from the 32-preset
catalog. voice (Xiaomi reference audio), imageUrl, and scene are untouched;
only the new stepfunVoiceId field is appended.
All 147 characters across both orientation sets are enriched (0 failures).
Make homepage cards and live sessions produce sound when the server is
configured for StepFun TTS, instead of silently failing (the prebaked
Xiaomi voice was useless on a StepFun server, and wasted ~220KB/beat in
Fast Origin Transfer).
Three coordinated changes:
1. CharacterDesigner now picks a StepFun preset voice id directly from the
32-entry catalog in the SAME LLM call that designs the character — zero
extra latency, LLM-grade match quality. The Xiaomi prompt path is
byte-identical to history (verified programmatically) so cache hit rate
and voice quality are preserved. pickStepfunVoiceId (keyword scorer)
remains the fallback for orphan speakers / invalid LLM picks.
2. The 32-preset catalog moves to lib/tts-client/stepfun-voices.json as the
single source of truth, shared by the scorer, the CharacterDesigner
prompt, /api/tts-provider, and the offline enrich script.
3. A new GET /api/tts-provider endpoint lets the client probe the server's
TTS provider at /play mount. fetchBeatAudio then shapes its request body:
on a StepFun server it sends the lightweight stepfunVoiceId /
voiceDescription and omits the ~220KB Xiaomi reference audio (FOT saving
~13MB per protagonist per session on prebaked cards). requestBeatAudio
re-provisions on a provider mismatch before synth, so audio never goes
silent on a cross-provider replay or mid-session provider flip.
New type fields are all optional and backward-compatible: Character.stepfunVoiceId,
BeatAudioRequest.voiceDescription/characterName/stepfunVoiceId, voice made
optional. AGENTS.md updated for the new route, type fields, dependency map,
and StepFun voice-selection flow.
Distinguish between temporary server-side processing and persistent
storage to accurately reflect the actual data flow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three follow-ups to ef3b579 (OpenAI SDK migration) and ebe39ef (canvas frame):
- .env.example / config.ts / AGENTS.md: anthropic & google native protocols
were removed with the Vercel AI SDK, but .env.example and AGENTS.md still
advertised them. Rewrite the docs to point Claude/Gemini at their
OpenAI-compatible endpoints (api.anthropic.com/v1,
generativelanguage.googleapis.com/v1beta/openai), drop the dead Gemini
"Nano Banana" image example, sync AGENTS.md (text/vision protocol list,
image protocol list, the "OpenAI/Gemini via AI SDK" reference note), and
append a short hint in readProvider() error message guiding
anthropic/google users to openai_compatible instead of a bare rejection.
- chat.ts: drop the unsafe `as { prompt_tokens_details?: ... }` cast; read
cached_tokens straight off the SDK's CompletionUsage type. Add a comment
noting the OpenAI usage object reports cache reads only (no cache-write
count), so the create cost the old AI SDK path logged is unrecoverable.
- PlayCanvas.tsx: revert <img key={imageUrl}> to key={imageUrl.slice(-48)}.
The gpt-image/mock paths emit multi-MB data URIs; using the full string as
React's reconciliation key adds avoidable diff overhead during the frequent
re-renders. Matches the existing <audio> element's key convention.
Validation: pnpm typecheck passes. (pnpm lint fails on a pre-existing Next 16
`next lint` CLI issue, identical on staging — unrelated to this change.)
Reverts the regressions from b63b694 on the server-fallback path:
P0 — fetchBeatAudio non-BYO branch was a bare return; every non-BYO
user got silent playback regardless of server TTS config. Re-connect
to /api/beat-audio with the beatAudioAbortRef signal, count 204/!ok
as silence strikes, create a blob URL on success.
P1 — stripVoicesForTransport + mergeCharactersPreserveVoice were
deleted, so the server-fallback path re-sent ~160KB
referenceAudioBase64 per character on every request AND lost voices
for already-known characters after scene 1. Re-add both, applied
ONLY on the server-fallback branches in engineClient.ts (BYO
client-direct path untouched).
P3 — the aborted-before-store blob URL race had no revoke, leaking
one blob URL per cancelled synth. Re-add the else-if revoke.
P2 — handleSettingsSaved ignored ttsConfigured, so a BYO key entered
mid-session only took effect after a page reload. Re-add the ref/state
refresh + audio re-prefetch. Also restore the silence-nudge UI
(silenceStrikes counter, SILENCE_NUDGE_THRESHOLD, dismissible pill
beside the mute toggle) that surfaces BYO-key guidance when the
shared server key is being rate-limited.
Verified live: /api/beat-audio now returns 200 (was 0 calls under
the bug); audio plays after synth completes.
- Rename "自带配音 Key" → "配音模型", drop the section-level "可选" badge,
and switch its icon to fa-volume-high to match the other model sections
- Drop redundant manual letter-spacing and "·" separators from settings
field labels (let .smallcaps tracking handle spacing)
- Move the CORS endpoint note to the top of the Models tab
- Home hint: reword to "输入想法", mention text/image/vision models + voice
key, and add an AUTH_ENABLED-gated "测试期间,登录即可免费畅玩" line
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(home): localize character portrait URLs in prebaked first-act JSONs
Runware CDN URLs expire, breaking character portraits in prebaked story
cards. Download all 144 portraits as static WebP assets and rewrite
first-act JSONs to reference local paths instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(scripts): add fetch timeout and simplify resize logic
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Dockerfile.opendeploy: local OpenDeploy build that hardcodes the public
image-proxy URL; kept out of the repo so a public fork doesn't route image
traffic through our Cloudflare Worker.
- .opendeploy: OpenDeploy CLI local context/credentials dir.
- pitch/: local pitch materials.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>