Merge pull request #47 from zonghaoyuan/staging
Release: staging → main
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
.next
|
||||||
|
.open-next
|
||||||
|
.wrangler
|
||||||
|
.vercel
|
||||||
|
.turbo
|
||||||
|
.claude
|
||||||
|
.git
|
||||||
|
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
out
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
repomix-output.xml
|
||||||
|
users.md
|
||||||
@@ -124,3 +124,14 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
|||||||
# domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards.
|
# domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards.
|
||||||
# Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com
|
# Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com
|
||||||
NEXT_PUBLIC_UMAMI_DOMAINS=
|
NEXT_PUBLIC_UMAMI_DOMAINS=
|
||||||
|
|
||||||
|
# ---- 7. Gallery share files (optional — leave blank to disable) ----
|
||||||
|
# Server-side secret used to AES-256-GCM encrypt a played session into a
|
||||||
|
# binary `.infiplot` share file the player can send to a friend. Friends drop
|
||||||
|
# the file into /gallery; the server decrypts and renders the same interactive
|
||||||
|
# replay. GCM's built-in auth tag also gives tamper-detection for free.
|
||||||
|
# Blank → "导出分享文件" is hidden, only the same-browser localStorage flow
|
||||||
|
# remains. Set to any high-entropy string ≥ 32 chars (e.g. `openssl rand -hex 32`).
|
||||||
|
# WARNING: rotating this secret invalidates every share file ever issued
|
||||||
|
# (decryption will fail with "文件校验失败"). Only change when you're OK with that.
|
||||||
|
GALLERY_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Build and push Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix=sha-
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: PR Agent
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, ready_for_review, synchronize]
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review-claude:
|
||||||
|
if: >
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
(github.event_name == 'issue_comment' &&
|
||||||
|
github.event.issue.pull_request &&
|
||||||
|
startsWith(github.event.comment.body, '/') &&
|
||||||
|
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: PR Agent - Claude Opus 4.7
|
||||||
|
uses: the-pr-agent/pr-agent@main
|
||||||
|
env:
|
||||||
|
OPENAI.KEY: ${{ secrets.PR_REVIEW_API_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
config.model: "openai/claude-opus-4-7"
|
||||||
|
config.fallback_models: '["openai/claude-opus-4-6"]'
|
||||||
|
config.custom_model_max_tokens: 200000
|
||||||
|
openai.api_base: ${{ secrets.PR_REVIEW_BASE_URL }}
|
||||||
|
github_action_config.auto_review: "true"
|
||||||
|
github_action_config.auto_describe: "true"
|
||||||
|
github_action_config.auto_improve: "true"
|
||||||
|
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "synchronize"]'
|
||||||
|
|
||||||
|
review-gpt:
|
||||||
|
if: >
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
(github.event_name == 'issue_comment' &&
|
||||||
|
github.event.issue.pull_request &&
|
||||||
|
startsWith(github.event.comment.body, '/') &&
|
||||||
|
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: PR Agent - GPT 5.5
|
||||||
|
uses: the-pr-agent/pr-agent@main
|
||||||
|
env:
|
||||||
|
OPENAI.KEY: ${{ secrets.PR_REVIEW_API_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
config.model: "openai/gpt-5.5"
|
||||||
|
config.fallback_models: '["openai/gpt-5.4-mini"]'
|
||||||
|
config.custom_model_max_tokens: 200000
|
||||||
|
openai.api_base: ${{ secrets.PR_REVIEW_BASE_URL }}
|
||||||
|
github_action_config.auto_review: "true"
|
||||||
|
github_action_config.auto_describe: "false"
|
||||||
|
github_action_config.auto_improve: "true"
|
||||||
|
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "synchronize"]'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[config]
|
||||||
|
ai_timeout = 300
|
||||||
|
temperature = 0.2
|
||||||
|
|
||||||
|
[pr_reviewer]
|
||||||
|
num_code_suggestions = 4
|
||||||
|
inline_code_comments = true
|
||||||
|
require_security_review = true
|
||||||
|
extra_instructions = """
|
||||||
|
This is a Next.js 16 / React 19 / TypeScript interactive visual novel engine.
|
||||||
|
Focus on: logic errors, security vulnerabilities, missing error handling,
|
||||||
|
type safety issues, and architectural violations.
|
||||||
|
Do not comment on code style or formatting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[pr_description]
|
||||||
|
generate_ai_title = true
|
||||||
|
publish_labels = true
|
||||||
|
use_bullet_points = true
|
||||||
|
|
||||||
|
[ignore]
|
||||||
|
glob = [
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"*.generated.*",
|
||||||
|
"public/**",
|
||||||
|
"docs/**",
|
||||||
|
"*.md"
|
||||||
|
]
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
This is the primary working guide for AI coding agents and contributors. It summarizes the repo-specific rules and adds contributor workflow guidance. Prefer it over generic Next.js assumptions.
|
||||||
|
|
||||||
|
## Project Structure & First Reads
|
||||||
|
|
||||||
|
InfiPlot is a Next.js 16 / React 19 / TypeScript app for AI-driven interactive visual novels (galgame). The server is intentionally stateless: the client carries the full `Session` and sends it to API routes whenever new generation is needed.
|
||||||
|
|
||||||
|
- `app/`: App Router pages and API routes. Start here for request/response behavior.
|
||||||
|
- `app/page.tsx`: Home/custom-start flow, preset cards, style-image upload/parsing, and analytics.
|
||||||
|
- `app/play/page.tsx`: Client session runtime, speculative scene prefetch, voice retention/stripping, image preload/proxying, orientation locking, and API callers.
|
||||||
|
- `components/`: Client UI, especially `PlayCanvas.tsx`, `CustomForm.tsx`, `PresetCard.tsx`, `TtsKeyModal.tsx`, and `Analytics.tsx`.
|
||||||
|
- `lib/types/index.ts`: Shared domain contracts. Read this before changing payload shapes.
|
||||||
|
- `lib/engine/`: Core story engine. `director.ts` orchestrates scene generation.
|
||||||
|
- `lib/engine/agents/`: Architect, Writer, CharacterDesigner, Cinematographer, Painter.
|
||||||
|
- `lib/engine/prompts.ts`: Agent prompts and prompt-cache-sensitive message builders.
|
||||||
|
- `lib/ai-client/`: Text, image, vision, and retry wrappers.
|
||||||
|
- `lib/tts-client/`: TTS integration.
|
||||||
|
- `lib/config.ts`: Server-side provider/environment loading.
|
||||||
|
- `lib/presets.ts`, `lib/ttsPresets.ts`, `lib/options.ts`: Home-page presets and selectable options.
|
||||||
|
- `scripts/`: Asset and preset generation helpers.
|
||||||
|
- `public/`, `docs/`: Static assets and documentation imagery.
|
||||||
|
|
||||||
|
For engine work, read `lib/types/index.ts`, the target agent/orchestrator file, and the API route exposing the behavior. For UI work, inspect the component and the owning page.
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
The engine behaves like `Session + EngineConfig -> SceneResult`. The client appends returned scenes to `session.history`, replaces `session.characters` and `session.storyState`, and sends the updated `Session` back later. Do not introduce server-side session storage, hidden global game state, or persistence unless explicitly requested.
|
||||||
|
|
||||||
|
The core pipeline is `directScene()` in `lib/engine/director.ts`. Writer is intentionally split into two phases so image generation can begin before full dialogue is ready:
|
||||||
|
|
||||||
|
1. Writer Phase A runs serially and produces `WriterPlan`: `sceneSummary`, `sceneKey`, `entryBeatId`, `cast`, `entryActiveCharacters`, and `entrySpeaker`.
|
||||||
|
2. Writer Phase B starts immediately and overlaps the image pipeline. It produces `beats[]` and `storyStatePatch`, constrained to honor the plan.
|
||||||
|
3. CharacterDesigner card LLMs and Cinematographer run in parallel from the plan.
|
||||||
|
4. Entry-beat portraits may block Painter because they become references.
|
||||||
|
5. Painter generates the scene background from Cinematographer `integratedPrompt` plus `referenceImages`.
|
||||||
|
6. Non-entry portraits and all voice provisioning should overlap with painting, then Phase B is awaited before scene assembly.
|
||||||
|
|
||||||
|
Do not add blocking calls between Writer Phase A completion and Painter start. Anything that can overlap with Phase B or painting should.
|
||||||
|
|
||||||
|
At session start, `startSession()` runs Architect first to create `storyState`; subsequent scene requests must rely on the client-carried `Session`, not server memory.
|
||||||
|
|
||||||
|
## Domain Model Invariants
|
||||||
|
|
||||||
|
`Scene` is an image plus a graph of `Beat` nodes. `Beat.next` is either `continue` or `choice`. A scene should have at least one meaningful `change-scene` exit toward a new scene. Beat ids are graph keys; keep them unique and repair references when coercing LLM output.
|
||||||
|
|
||||||
|
`StoryState` has stable and volatile zones. Stable fields are set by Architect and must not be patched by Writer: `logline`, `genreTags`, `protagonist`, `castNotes`. Volatile fields may be rewritten every scene: `synopsis`, `openThreads`, `relationships`, `nextHook`. If adding a field, classify it and update `applyStoryStatePatch()` plus Writer coercion.
|
||||||
|
|
||||||
|
Characters are identified by `name`. `mergeCharacters()` preserves existing portrait and voice fields when a later design omits them. Do not casually change character matching without checking Writer, Director, and Painter reference handling.
|
||||||
|
|
||||||
|
The player POV is hardcoded as second-person Chinese `"你"`. The player should not appear in `activeCharacters`, images, portraits, or TTS. Preserve normalization in Writer and InsertBeat flows.
|
||||||
|
|
||||||
|
`orientation` is session-wide and locked at start (`"portrait"` for upright touch devices, otherwise `"landscape"`). It controls prompt framing, generated dimensions, mock images, and `PlayCanvas` layout; preserve back-compat by coercing missing/invalid values to `"landscape"`.
|
||||||
|
|
||||||
|
`styleReferenceImage` is an optional client-resized `data:image/...` reference stored in the carried `Session`. It can make request bodies large, so keep validation limits and client resizing intact.
|
||||||
|
|
||||||
|
## Agent Output & Error Handling
|
||||||
|
|
||||||
|
Agent outputs should follow the existing pattern:
|
||||||
|
|
||||||
|
1. Raw LLM type accepts optional and variant fields.
|
||||||
|
2. Coercion normalizes names, defaults, and malformed values.
|
||||||
|
3. Repair fixes structural issues.
|
||||||
|
4. Fallback returns a safe value instead of throwing at the agent boundary.
|
||||||
|
|
||||||
|
Never use direct `JSON.parse()` on core agent LLM output. Use `parseJsonLoose()` from `lib/engine/jsonParser.ts`, which attempts direct parse, fenced JSON extraction, object slicing, and `jsonrepair`. Narrow utility routes may parse first only when they also have a safe fallback, as `/api/parse-style-image` does.
|
||||||
|
|
||||||
|
Maintain graceful degradation. Existing flows tolerate malformed AI JSON, failed character cards, failed portraits, failed TTS, failed image references, optional analytics, and provider timeouts. Do not convert optional provider failures into hard crashes.
|
||||||
|
|
||||||
|
## Visual Continuity & Prompt Caching
|
||||||
|
|
||||||
|
`sceneKey` identifies a physical space such as `"classroom-dusk"`. If a new scene shares a key with prior history, the prior scene image should be reused as a reference. Character portraits are also references.
|
||||||
|
|
||||||
|
Runware allows at most 4 references. Preserve the priority: style reference image, prior scene, speaker portrait, then other NPCs. Prefer image URLs for `referenceImages` when needed because Runware can fail to recognize UUIDs. The OpenAI/Gemini image paths can also accept references through the AI SDK, but they return data URIs and synthetic UUIDs, so repeated session transport is heavier than Runware's URL/UUID loop.
|
||||||
|
|
||||||
|
Writer prompt caching depends on `buildWriterPlanUserMessage()` and `buildWriterBeatsUserMessage()` keeping their stable prefixes intact: world, style, story spine, archived history, known scene keys, and character list. The dynamic suffix contains current state, last beat, exit hint, and the current plan. Do not reorder or reformat stable prefix sections casually; it can destroy cache hit rates.
|
||||||
|
|
||||||
|
## API Flow
|
||||||
|
|
||||||
|
Common routes live under `app/api/`:
|
||||||
|
|
||||||
|
- `POST /api/start`: starts a session via Architect then `directScene()`.
|
||||||
|
- `POST /api/scene`: generates the next scene from an existing session.
|
||||||
|
- `POST /api/vision`: interprets scene-image clicks.
|
||||||
|
- `POST /api/insert-beat`: creates a transient beat without image generation.
|
||||||
|
- `POST /api/beat-audio`: lazy TTS for a displayed beat; returns binary audio, or `204` when silent.
|
||||||
|
- `POST /api/parse-style-image`: extracts a style prompt from uploaded reference art.
|
||||||
|
|
||||||
|
When changing public types or route payloads, update all route callers and client consumers in the same change.
|
||||||
|
|
||||||
|
All API routes currently run on `runtime = "nodejs"`. Keep Cloudflare implications in mind before adding Node-only dependencies to code that should also work in browser/client or OpenNext builds.
|
||||||
|
|
||||||
|
The client deliberately strips `voice.referenceAudioBase64` from `Session` before `/api/scene`, `/api/vision`, and `/api/insert-beat` transport, then merges voices back locally. Server responses strip already-known voices to reduce payload size. Preserve this first-load/request-size behavior when changing character or TTS flow.
|
||||||
|
|
||||||
|
`clientTts: true` means the browser owns Xiaomi TTS keys and provisions/synthesizes voices locally; routes must drop `config.tts` so server-side TTS is skipped and user keys never touch the server.
|
||||||
|
|
||||||
|
`app/play/page.tsx` speculatively prefetches future `/api/scene` responses up to `PREFETCH_MAX_DEPTH`. If scene/session shape changes, update speculative session construction, cache re-rooting, abort logic, and voice/image preload handling together.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use pnpm with Node >=22. `pnpm-lock.yaml` is the source of truth; `package-lock.json` is legacy and should not be updated unless requested.
|
||||||
|
|
||||||
|
- `pnpm dev`: local Next.js dev server.
|
||||||
|
- `pnpm build`: production build for Vercel/default target.
|
||||||
|
- `pnpm start`: run production server after building.
|
||||||
|
- `pnpm lint`: Next.js built-in lint.
|
||||||
|
- `pnpm typecheck`: `tsc --noEmit`.
|
||||||
|
- `pnpm build:cf`: Cloudflare Workers build through OpenNext.
|
||||||
|
- `pnpm preview:cf`: local Cloudflare preview.
|
||||||
|
- `pnpm deploy:cf`: Cloudflare deploy.
|
||||||
|
|
||||||
|
There is no dedicated test framework, no Prettier config, and no standalone ESLint config. Before handing off code changes, run `pnpm typecheck` and `pnpm lint`; run `pnpm build` for routing, deployment, or provider initialization changes.
|
||||||
|
|
||||||
|
## Coding Style & Imports
|
||||||
|
|
||||||
|
Write TypeScript with 2-space indentation, double quotes, semicolons, and ESM imports. Prefer named exports for shared helpers and components when practical.
|
||||||
|
|
||||||
|
Use aliases from `tsconfig.json`: `@/*`, `@infiplot/engine`, `@infiplot/ai-client`, `@infiplot/tts-client`, and `@infiplot/types`. Avoid deep relative import chains when an alias exists.
|
||||||
|
|
||||||
|
React components use PascalCase. Hooks, helpers, variables, and functions use camelCase. Types and interfaces use PascalCase. Route folders follow Next.js App Router conventions. UI work should follow the existing Tailwind-heavy visual language.
|
||||||
|
|
||||||
|
Modal/dialog UI should be extracted into dedicated components instead of being inlined inside large page or canvas components. Keep the host responsible for open/close state and domain data, and keep the modal component responsible for dialog layout, overlay behavior, keyboard close handling, scroll containers, and modal-specific styling.
|
||||||
|
|
||||||
|
Comment only non-obvious sequencing, provider quirks, fallback behavior, or architectural invariants.
|
||||||
|
|
||||||
|
## Configuration & Providers
|
||||||
|
|
||||||
|
Use `.env.example` as the source of truth. Never commit `.env.local`, API keys, uploaded user content, or generated secrets.
|
||||||
|
|
||||||
|
- Text and Vision use `TEXT_*` and `VISION_*`; default protocol is `openai_compatible`, with native `anthropic` and `google` available via `TEXT_PROVIDER` / `VISION_PROVIDER`.
|
||||||
|
- Image uses `IMAGE_*`; supported protocols are `runware`, `openai_compatible`, native `openai`, and native `google`. When `IMAGE_PROVIDER` is unset, Runware is inferred from `*.runware.ai` URLs and otherwise falls back to OpenAI-compatible image generations.
|
||||||
|
- TTS uses Xiaomi MiMo protocol and is optional: blank config means silent mode.
|
||||||
|
- `MOCK_IMAGE=true` skips image generation and returns a placeholder for cheap local iteration.
|
||||||
|
- `NEXT_PUBLIC_IMAGE_PROXY_URL` and `NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS` opt into browser-side image proxying for allowed hosts.
|
||||||
|
- Analytics uses optional Umami `NEXT_PUBLIC_UMAMI_*` values and must stay content-free/privacy-preserving.
|
||||||
|
- `NEXT_PUBLIC_*` values are inlined at build time.
|
||||||
|
|
||||||
|
## File Dependency Map
|
||||||
|
|
||||||
|
If modifying Writer, also check `director.ts`, `prompts.ts`, WriterPlan/StoryState types, and Cinematographer/Painter consumers. If modifying CharacterDesigner, check Director scheduling/merge logic, portrait prompts, voice provisioning, and Painter reference collection. If modifying Cinematographer or Painter, check Director, prompt builders, provider image options, orientation handling, and reference priority. If modifying Architect, check `orchestrator.ts`, `prompts.ts`, and StoryState patch rules. If modifying `lib/types/index.ts`, check all agents, Director, Orchestrator, API routes, and client consumers in `app/page.tsx`, `app/play/page.tsx`, and `components/PlayCanvas.tsx`. If modifying TTS, check server `beat-audio`, BYO client TTS, voice stripping/merging, and payload privacy. If modifying image delivery, check Painter, `lib/ai-client/image.ts`, mock images, orientation dimensions, preload/proxy logic, and style-reference validation.
|
||||||
|
|
||||||
|
## Guide Maintenance
|
||||||
|
|
||||||
|
After any refactor, architecture change, provider-client rewrite, public type change, new route, payload-shape change, or major UI flow change, reread the affected files and compare them against this `AGENTS.md`. Update `AGENTS.md` in the same change if the architecture, commands, invariants, dependency map, environment variables, or "What Not To Do" list drifted. The canonical filename is `AGENTS.md`; treat mentions like `AGETNS.md` as typos and repair the real file.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
Follow observed Conventional Commit style: `feat(web): ...`, `fix(play): ...`, `perf(engine): ...`, `chore(engine): ...`.
|
||||||
|
|
||||||
|
PRs should include a short behavior summary, validation commands run, linked issues when relevant, screenshots or recordings for UI changes, and notes for environment, provider, deployment, or payload-shape changes.
|
||||||
|
|
||||||
|
## What Not To Do
|
||||||
|
|
||||||
|
- Do not make the server stateful.
|
||||||
|
- Do not generate images, portraits, or TTS for `"你"`.
|
||||||
|
- Do not let Writer patch stable `StoryState` fields.
|
||||||
|
- Do not reorder the Writer stable prompt prefix without a clear cache-aware reason.
|
||||||
|
- Do not assume Runware UUID references always work.
|
||||||
|
- Do not remove fallbacks, timeout handling, analytics privacy constraints, or reference priority rules.
|
||||||
|
- Do not leak browser-provided TTS keys to the server or send retained voice audio through scene/vision/insert-beat session payloads.
|
||||||
|
- Do not break session-locked orientation or style-reference propagation when changing start/play flows.
|
||||||
|
- Do not regenerate large assets in `public/` unless the user requested asset work.
|
||||||
|
- Do not mix prompt refactors, provider-client rewrites, UI restyling, and deployment changes in one narrow task.
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
|
# --- deps: install production + dev dependencies (cached layer) ---
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# --- builder: build Next.js standalone output ---
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV BUILD_STANDALONE=true
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# --- runner: minimal production image ---
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+24
-2
@@ -37,14 +37,36 @@ Free to play, no setup required: [infiplot.com](https://infiplot.com)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## One-click deploy
|
## Deploy
|
||||||
|
|
||||||
InfiPlot deploys to both Vercel and Cloudflare Workers. Cloudflare deployment requires the Workers Paid Plan because the scene pipeline needs longer CPU time; for personal use, the one-click Vercel deploy is recommended.
|
InfiPlot offers multiple deployment options. For personal use, we recommend the one-click Vercel deploy; to self-host on your own server or local machine, use Docker.
|
||||||
|
|
||||||
|
### Vercel / Cloudflare (one-click)
|
||||||
|
|
||||||
|
Cloudflare deployment requires the Workers Paid Plan because the scene pipeline needs longer CPU time.
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot/blob/main/README.en.md%23configuration-guide) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot)
|
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot/blob/main/README.en.md%23configuration-guide) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot)
|
||||||
|
|
||||||
After deploy, fill in the environment variables — see the [Configuration guide](#configuration-guide) below. The repo root is the app itself: Vercel needs no special root directory; on Cloudflare, just set the build command to `pnpm build:cf`.
|
After deploy, fill in the environment variables — see the [Configuration guide](#configuration-guide) below. The repo root is the app itself: Vercel needs no special root directory; on Cloudflare, just set the build command to `pnpm build:cf`.
|
||||||
|
|
||||||
|
### Docker (self-hosted)
|
||||||
|
|
||||||
|
For VPS, home servers, or local machines. Supports x86 and ARM (including Apple Silicon Macs).
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env.local` and fill in your API keys (see [Configuration guide](#configuration-guide))
|
||||||
|
2. Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:3000` to start playing.
|
||||||
|
|
||||||
|
> You can also run the image directly without Compose:
|
||||||
|
> ```bash
|
||||||
|
> docker run -d -p 3000:3000 --env-file .env.local ghcr.io/zonghaoyuan/infiplot:latest
|
||||||
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|||||||
+24
-2
@@ -37,14 +37,36 @@ InfiPlot は、AI がコンテンツをリアルタイムに生成するイン
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ワンクリックデプロイ
|
## デプロイ
|
||||||
|
|
||||||
InfiPlot は Vercel と Cloudflare Workers の両方にデプロイできます。Cloudflare へのデプロイはシーンパイプラインがより長い CPU 時間を必要とするため、Workers Paid Plan が必要です。個人利用には Vercel のワンクリックデプロイをおすすめします。
|
InfiPlot は複数のデプロイ方法に対応しています。個人利用には Vercel のワンクリックデプロイをおすすめします。自分のサーバーやローカルマシンで動かしたい場合は Docker を使ってください。
|
||||||
|
|
||||||
|
### Vercel / Cloudflare(ワンクリック)
|
||||||
|
|
||||||
|
Cloudflare へのデプロイはシーンパイプラインがより長い CPU 時間を必要とするため、Workers Paid Plan が必要です。
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot/blob/main/README.ja.md%23%E8%A8%AD%E5%AE%9A%E3%82%AC%E3%82%A4%E3%83%89) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot)
|
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot/blob/main/README.ja.md%23%E8%A8%AD%E5%AE%9A%E3%82%AC%E3%82%A4%E3%83%89) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot)
|
||||||
|
|
||||||
デプロイ後、環境変数を設定してください —— 下記の[設定ガイド](#設定ガイド)を参照。リポジトリのルートがアプリ本体です:Vercel では特別なルート設定は不要です。Cloudflare ではビルドコマンドを `pnpm build:cf` に設定するだけで済みます。
|
デプロイ後、環境変数を設定してください —— 下記の[設定ガイド](#設定ガイド)を参照。リポジトリのルートがアプリ本体です:Vercel では特別なルート設定は不要です。Cloudflare ではビルドコマンドを `pnpm build:cf` に設定するだけで済みます。
|
||||||
|
|
||||||
|
### Docker デプロイ(セルフホスト)
|
||||||
|
|
||||||
|
VPS、ホームサーバー、ローカルマシンに対応。x86 と ARM(Apple Silicon Mac を含む)をサポート。
|
||||||
|
|
||||||
|
1. `.env.example` を `.env.local` にコピーし、API キーを設定([設定ガイド](#設定ガイド)を参照)
|
||||||
|
2. 起動:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`http://localhost:3000` にアクセスしてゲームを開始できます。
|
||||||
|
|
||||||
|
> Compose を使わず、直接イメージを実行することもできます:
|
||||||
|
> ```bash
|
||||||
|
> docker run -d -p 3000:3000 --env-file .env.local ghcr.io/zonghaoyuan/infiplot:latest
|
||||||
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 スクリーンショット
|
## 📸 スクリーンショット
|
||||||
|
|||||||
@@ -37,14 +37,36 @@ InfiPlot是一款AI实时生成内容的互动剧情游戏,这里没有预设
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一键部署
|
## 部署
|
||||||
|
|
||||||
InfiPlot 同时支持部署到 Vercel 与 Cloudflare Workers。Cloudflare 部署因场景流水线需要更长 CPU 时间,需要 Workers Paid Plan;个人使用推荐用 Vercel 一键部署。
|
InfiPlot 支持多种部署方式。个人使用推荐 Vercel 一键部署;想部署到自己的服务器或本地运行,可以用 Docker。
|
||||||
|
|
||||||
|
### Vercel / Cloudflare(一键部署)
|
||||||
|
|
||||||
|
Cloudflare 部署因场景流水线需要更长 CPU 时间,需要 Workers Paid Plan。
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot%23%E9%85%8D%E7%BD%AE%E6%95%99%E7%A8%8B) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot)
|
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot%23%E9%85%8D%E7%BD%AE%E6%95%99%E7%A8%8B) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot)
|
||||||
|
|
||||||
部署完成后,填好环境变量 —— 详见下方的[配置教程](#配置教程)。仓库根目录就是应用本身:Vercel 无需额外设置 root directory;在 Cloudflare 上把构建命令设为 `pnpm build:cf` 即可。
|
部署完成后,填好环境变量 —— 详见下方的[配置教程](#配置教程)。仓库根目录就是应用本身:Vercel 无需额外设置 root directory;在 Cloudflare 上把构建命令设为 `pnpm build:cf` 即可。
|
||||||
|
|
||||||
|
### Docker 部署(自托管)
|
||||||
|
|
||||||
|
适用于 VPS、家庭服务器或本地电脑。支持 x86 和 ARM(含 Apple Silicon Mac)。
|
||||||
|
|
||||||
|
1. 复制 `.env.example` 为 `.env.local`,填入你的 API Key(详见[配置教程](#配置教程))
|
||||||
|
2. 启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:3000` 即可开始游戏。
|
||||||
|
|
||||||
|
> 也可以不用 Compose,直接运行镜像:
|
||||||
|
> ```bash
|
||||||
|
> docker run -d -p 3000:3000 --env-file .env.local ghcr.io/zonghaoyuan/infiplot:latest
|
||||||
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 游戏截图
|
## 📸 游戏截图
|
||||||
@@ -104,7 +126,7 @@ InfiPlot 同时支持部署到 Vercel 与 Cloudflare Workers。Cloudflare 部署
|
|||||||
|
|
||||||
## 团队与愿景
|
## 团队与愿景
|
||||||
|
|
||||||
我们是一群来自清华大学等高校的年轻人。
|
我们是一群来自清华大学、兰州大学、西安交通大学等高校的年轻人。
|
||||||
|
|
||||||
一方面,我们本来就是galgame、乙女游戏、FMV、AI角色扮演游戏这类游戏的深度用户,在享受游戏体验的同时,也会想象如果能选择不被预设的剧情选项,或者和对话的AI角色深度互动而不只是通过聊天软件聊天,该是多么愉快刺激的体验。
|
一方面,我们本来就是galgame、乙女游戏、FMV、AI角色扮演游戏这类游戏的深度用户,在享受游戏体验的同时,也会想象如果能选择不被预设的剧情选项,或者和对话的AI角色深度互动而不只是通过聊天软件聊天,该是多么愉快刺激的体验。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { classifyFreeform } from "@infiplot/engine";
|
||||||
|
import type { FreeformClassifyRequest } from "@infiplot/types";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: FreeformClassifyRequest;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as FreeformClassifyRequest;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.session || !body.freeformText?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "session and freeformText are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = loadEngineConfig();
|
||||||
|
const result = await classifyFreeform(config, body);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { packDoc } from "@/lib/galleryCrypto";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const MAX_DOC_BYTES = 5_000_000;
|
||||||
|
|
||||||
|
// Encrypt a gallery doc into the shareable `.infiplot` binary format.
|
||||||
|
// Stateless: input is the doc string, output is the encrypted bytes — server
|
||||||
|
// keeps nothing. The secret must be configured (no insecure fallback).
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
const secret = process.env.GALLERY_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "图集分享未启用 (GALLERY_SECRET 未配置)" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let docStr: string;
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as { docStr?: unknown };
|
||||||
|
if (typeof body.docStr !== "string") {
|
||||||
|
return Response.json({ error: "Missing docStr" }, { status: 400 });
|
||||||
|
}
|
||||||
|
docStr = body.docStr;
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Bad JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new TextEncoder().encode(docStr).byteLength > MAX_DOC_BYTES) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "图集数据太大,无法打包分享" },
|
||||||
|
{ status: 413 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await packDoc(docStr, secret);
|
||||||
|
// Copy into a fresh ArrayBuffer so TS 5.7's stricter BodyInit typing accepts
|
||||||
|
// it (Uint8Array.buffer is ArrayBufferLike, which the BodyInit overloads
|
||||||
|
// don't narrow). Cheap — one extra alloc + memcpy of ~50-200KB.
|
||||||
|
const ab = new ArrayBuffer(bytes.byteLength);
|
||||||
|
new Uint8Array(ab).set(bytes);
|
||||||
|
return new Response(ab, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { unpackDoc } from "@/lib/galleryCrypto";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// Cap a bit above pack's MAX_DOC_BYTES — ciphertext adds the 16-byte GCM tag
|
||||||
|
// and the 17-byte header; some slack accommodates near-cap docs without
|
||||||
|
// rejecting them at unpack time.
|
||||||
|
const MAX_FILE_BYTES = 6_000_000;
|
||||||
|
|
||||||
|
// Decrypt a `.infiplot` share file back to its doc JSON string. Returns the
|
||||||
|
// plaintext as a JSON field (not raw bytes) so the client can chain it through
|
||||||
|
// JSON.parse without sniffing the response type. Errors are deliberately
|
||||||
|
// generic — we don't distinguish "wrong key" from "tampered file" because the
|
||||||
|
// distinction would leak server config.
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
const secret = process.env.GALLERY_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "图集分享未启用 (GALLERY_SECRET 未配置)" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ab: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
ab = await req.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Bad request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (ab.byteLength > MAX_FILE_BYTES) {
|
||||||
|
return Response.json({ error: "文件太大" }, { status: 413 });
|
||||||
|
}
|
||||||
|
if (ab.byteLength === 0) {
|
||||||
|
return Response.json({ error: "文件为空" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docStr = await unpackDoc(new Uint8Array(ab), secret);
|
||||||
|
return Response.json({ docStr });
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: e instanceof Error ? e.message : "解包失败" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,6 @@ export async function POST(req: Request) {
|
|||||||
config.vision,
|
config.vision,
|
||||||
body.imageDataUrl,
|
body.imageDataUrl,
|
||||||
STYLE_EXTRACTION_PROMPT,
|
STYLE_EXTRACTION_PROMPT,
|
||||||
{ responseFormat: "json_object" },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let parsed: { stylePrompt?: string };
|
let parsed: { stylePrompt?: string };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,42 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.32em;
|
letter-spacing: 0.32em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vn-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(195, 155, 75, 0.58) rgba(20, 14, 8, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vn-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vn-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0,
|
||||||
|
transparent 3px,
|
||||||
|
rgba(20, 14, 8, 0.46) 3px,
|
||||||
|
rgba(20, 14, 8, 0.46) 5px,
|
||||||
|
transparent 5px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vn-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(195, 155, 75, 0.52);
|
||||||
|
border: 2px solid rgba(14, 10, 6, 0.88);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vn-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(220, 180, 95, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vn-scrollbar::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes infiplot-ripple {
|
@keyframes infiplot-ripple {
|
||||||
|
|||||||
+311
-456
@@ -11,7 +11,7 @@ import {
|
|||||||
type Gender,
|
type Gender,
|
||||||
} from "@/lib/options";
|
} from "@/lib/options";
|
||||||
import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
|
import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
|
||||||
import { TtsKeyModal } from "@/components/TtsKeyModal";
|
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||||
@@ -53,22 +53,7 @@ const OPTS: Opt[] = [
|
|||||||
|
|
||||||
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
||||||
|
|
||||||
const STYLE_MAP: Record<string, string> = {
|
import { STYLE_MAP } from "@/lib/options";
|
||||||
"古典厚涂油画": "Dark fantasy oil painting style, grand clockwork steampunk city built into a mountain range at twilight, immense gothic spires with glowing green lamps, complex gears and platforms. Richly detailed, impasto texture, dramatic academic lighting. Horizontal cinematic composition.",
|
|
||||||
"极简中国水墨": "Minimalist Chinese ink wash style, vertical sea of clouds and distant jagged peaks. Ethereal, sparse composition with poetic brushstrokes, monochrome palette with subtle blue hints. Large blank mist area for copy space.",
|
|
||||||
"浮世绘木刻": "Ukiyo-e woodblock print style, majestic waves and Mount Fuji visible through cherry branches. Bold outlines, flat colors with paper texture, ancient and mystical atmosphere.",
|
|
||||||
"莫高窟壁画": "Dunhuang fresco style, celestial patterns, stylized lotus flowers and floating geometric patterns on an aged stucco wall. Muted, oxidized mineral colors, delicate line art, historical and divine ambiance.",
|
|
||||||
"波斯细密画": "Persian miniature style, ornate vertical tiled garden pavilion surrounded by tall cypress trees and complex geometric mosaics. High detail, jewel-like colors, flattened perspective, decorative borders.",
|
|
||||||
"吉卜力治愈手绘": "Ghibli hand-painted watercolor style, a vast wildflower meadow hill under a bright blue sky with fluffy clouds, a fantastical airship flying in the distance. Natural daylight, soft washes, nostalgic feel.",
|
|
||||||
"京阿尼细腻日常": "KyoAni anime style, fine line art, warm indoor lighting contrasting the cool moonlight outside, rain streaks on a tall window. Deep emotional atmosphere, delicate light and shadow reflections.",
|
|
||||||
"新海诚唯美光影": "Makoto Shinkai anime style, hyper-detailed, towering dramatic night starry sky with a descending comet trail, glowing cherry tree branches in the foreground. Brilliant lighting effects, vivid colors.",
|
|
||||||
"Galgame CG": "High-quality Galgame CG illustration, dreamlike beach scene at sunset with sparkling waves rolling in. Pastel colors, bloom lighting, clean composition, soft focus.",
|
|
||||||
"3D 动漫电影": "Cinematic 3D animated film style, a rustic wooden hangar at sunrise with volumetric lighting, warm golden hour colors, deep textures, cinematic composition.",
|
|
||||||
"赛博朋克": "Cyberpunk anime style, cel-shaded animation, rainy night streets of a dense neon-drenched futuristic megacity with towering skyscrapers. Hard edges, high saturation, sharp contrast.",
|
|
||||||
"蒸汽波": "Vaporwave aesthetic, anime style, a geometric pink grid floor leading to a palm tree silhouette, neon pink sunset over a purple ocean in the background. Glitch effects, retro pastel colors.",
|
|
||||||
"哥特庄园": "Gothic romance illustration, desolate moonlit ruins of a grand gothic manor on a foggy cliff, misty atmosphere, melancholic blue and grey tones.",
|
|
||||||
"废土科幻": "Post-apocalyptic landscape, vast desert wasteland with the rusted remains of an overgrown highway and ruined skyscrapers under a dusty orange sunset sky.",
|
|
||||||
};
|
|
||||||
|
|
||||||
/* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。
|
/* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。
|
||||||
男/女同索引共享画面尺寸,切性向 crossfade 时卡片高度不跳变。 */
|
男/女同索引共享画面尺寸,切性向 crossfade 时卡片高度不跳变。 */
|
||||||
@@ -157,7 +142,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
|||||||
{
|
{
|
||||||
"title": "社团存亡日",
|
"title": "社团存亡日",
|
||||||
"outline": "濒临废部的动画社,唯一社员是总在睡觉的怪人。新来的转校生社长发现,只要完成怪人的“日常委托”,社员就会增加一人,而这些人,都来自被遗忘的动画世界。",
|
"outline": "濒临废部的动画社,唯一社员是总在睡觉的怪人。新来的转校生社长发现,只要完成怪人的“日常委托”,社员就会增加一人,而这些人,都来自被遗忘的动画世界。",
|
||||||
"style": "京阿尼细腻日常 (Image 5参考)",
|
"style": "京阿尼 (Image 5参考)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"日常",
|
"日常",
|
||||||
"奇幻",
|
"奇幻",
|
||||||
@@ -167,7 +152,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
|||||||
{
|
{
|
||||||
"title": "黄昏归途",
|
"title": "黄昏归途",
|
||||||
"outline": "他总在黄昏时分,于空无一人的车站遇见少女。她带他穿越时间的缝隙,回到故乡被毁灭前的最后一天。每一次循环,他都必须在拯救她与拯救世界之间做出选择。",
|
"outline": "他总在黄昏时分,于空无一人的车站遇见少女。她带他穿越时间的缝隙,回到故乡被毁灭前的最后一天。每一次循环,他都必须在拯救她与拯救世界之间做出选择。",
|
||||||
"style": "新海诚唯美光影 (Image 2参考)",
|
"style": "新海诚 (Image 2参考)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"时间循环",
|
"时间循环",
|
||||||
"恋爱",
|
"恋爱",
|
||||||
@@ -459,7 +444,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
|||||||
{
|
{
|
||||||
"title": "夏日未完待续",
|
"title": "夏日未完待续",
|
||||||
"outline": "她在文化祭前夜,与青梅竹马的学长在空教室许下约定。第二天醒来,时间永远停在了文化祭前一周。只有她保留记忆,为守护他的笑容,她一遍遍重演青春,试图改写那个令他心碎的结局。",
|
"outline": "她在文化祭前夜,与青梅竹马的学长在空教室许下约定。第二天醒来,时间永远停在了文化祭前一周。只有她保留记忆,为守护他的笑容,她一遍遍重演青春,试图改写那个令他心碎的结局。",
|
||||||
"style": "京阿尼细腻日常 (Image 5参考)",
|
"style": "京阿尼 (Image 5参考)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"时间循环",
|
"时间循环",
|
||||||
"青春",
|
"青春",
|
||||||
@@ -469,7 +454,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
|||||||
{
|
{
|
||||||
"title": "星之轨迹",
|
"title": "星之轨迹",
|
||||||
"outline": "她总在雨天,于旧书店遇见来自未来的他。他说她是拯救未来的关键,赠予她能看到“命运线”的能力。当她终于能看清两人的轨迹,却发现他来自的时间线,正因她的存在而崩塌。",
|
"outline": "她总在雨天,于旧书店遇见来自未来的他。他说她是拯救未来的关键,赠予她能看到“命运线”的能力。当她终于能看清两人的轨迹,却发现他来自的时间线,正因她的存在而崩塌。",
|
||||||
"style": "新海诚唯美光影 (Image 2参考)",
|
"style": "新海诚 (Image 2参考)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"穿越",
|
"穿越",
|
||||||
"科幻",
|
"科幻",
|
||||||
@@ -865,8 +850,6 @@ function StyleModal({
|
|||||||
onClose,
|
onClose,
|
||||||
customStyleGuide,
|
customStyleGuide,
|
||||||
setCustomStyleGuide,
|
setCustomStyleGuide,
|
||||||
styleOverrides,
|
|
||||||
setStyleOverrides,
|
|
||||||
customStyleRefImage,
|
customStyleRefImage,
|
||||||
setCustomStyleRefImage,
|
setCustomStyleRefImage,
|
||||||
}: {
|
}: {
|
||||||
@@ -876,62 +859,83 @@ function StyleModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
customStyleGuide: string;
|
customStyleGuide: string;
|
||||||
setCustomStyleGuide: (s: string) => void;
|
setCustomStyleGuide: (s: string) => void;
|
||||||
styleOverrides: Record<string, string>;
|
|
||||||
setStyleOverrides: (o: Record<string, string>) => void;
|
|
||||||
customStyleRefImage: string;
|
customStyleRefImage: string;
|
||||||
setCustomStyleRefImage: (s: string) => void;
|
setCustomStyleRefImage: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [shown, setShown] = useState(false);
|
const [shown, setShown] = useState(false);
|
||||||
// Inline editing:editingIdx === i 时该卡片的 prompt 框变成可编辑 textarea。
|
const [view, setView] = useState<"grid" | "custom">("grid");
|
||||||
// 列表保持原位(不跳新页面),其他卡片继续可见——用户随时可以取消并切到别处。
|
|
||||||
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
// 上传 / 解析参考图的瞬时状态——失败/进行中提示只在此次弹窗内可见。
|
|
||||||
const [parsing, setParsing] = useState(false);
|
const [parsing, setParsing] = useState(false);
|
||||||
const [parseError, setParseError] = useState<string | null>(null);
|
const [parseError, setParseError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const thumbV = "v5";
|
||||||
|
const STYLE_THUMB: Record<string, string> = {
|
||||||
|
"自动": `/home/styles/auto.webp?${thumbV}`,
|
||||||
|
"自定义风格": `/home/styles/custom.webp?${thumbV}`,
|
||||||
|
"京阿尼": `/home/styles/kyoani.webp?${thumbV}`,
|
||||||
|
"新海诚": `/home/styles/shinkai.webp?${thumbV}`,
|
||||||
|
"吉卜力": `/home/styles/ghibli.webp?${thumbV}`,
|
||||||
|
"3D 动画": `/home/styles/3d.webp?${thumbV}`,
|
||||||
|
"赛博朋克": `/home/styles/cyberpunk.webp?${thumbV}`,
|
||||||
|
"哥特": `/home/styles/gothic.webp?${thumbV}`,
|
||||||
|
"废土": `/home/styles/wasteland.webp?${thumbV}`,
|
||||||
|
"像素风": `/home/styles/pixel.webp?${thumbV}`,
|
||||||
|
"真实": `/home/styles/real.webp?${thumbV}`,
|
||||||
|
"古典油画": `/home/styles/oil.webp?${thumbV}`,
|
||||||
|
"莫奈": `/home/styles/monet.webp?${thumbV}`,
|
||||||
|
"水彩": `/home/styles/watercolor.webp?${thumbV}`,
|
||||||
|
"水墨": `/home/styles/ink.webp?${thumbV}`,
|
||||||
|
"浮世绘": `/home/styles/ukiyoe.webp?${thumbV}`,
|
||||||
|
"彩铅": `/home/styles/pencil.webp?${thumbV}`,
|
||||||
|
"手绘素描": `/home/styles/sketch.webp?${thumbV}`,
|
||||||
|
"黑白漫画": `/home/styles/manga.webp?${thumbV}`,
|
||||||
|
"儿童绘本": `/home/styles/children.webp?${thumbV}`,
|
||||||
|
"儿童涂鸦": `/home/styles/crayon.webp?${thumbV}`,
|
||||||
|
"黏土手工": `/home/styles/clay.webp?${thumbV}`,
|
||||||
|
"敦煌壁画": `/home/styles/dunhuang.webp?${thumbV}`,
|
||||||
|
"细密画": `/home/styles/miniature.webp?${thumbV}`,
|
||||||
|
"镶嵌画": `/home/styles/mosaic.webp?${thumbV}`,
|
||||||
|
"彩绘玻璃": `/home/styles/stainedglass.webp?${thumbV}`,
|
||||||
|
"蒸汽波": `/home/styles/vaporwave.webp?${thumbV}`,
|
||||||
|
"矢量插画": `/home/styles/vector.webp?${thumbV}`,
|
||||||
|
"低多边形": `/home/styles/lowpoly.webp?${thumbV}`,
|
||||||
|
"波普艺术": `/home/styles/popart.webp?${thumbV}`,
|
||||||
|
"故障艺术": `/home/styles/glitch.webp?${thumbV}`,
|
||||||
|
"剪纸艺术": `/home/styles/papercut.webp?${thumbV}`,
|
||||||
|
"蒸汽朋克": `/home/styles/steampunk.webp?${thumbV}`,
|
||||||
|
"仙侠玄幻": `/home/styles/xianxia.webp?${thumbV}`,
|
||||||
|
"暗黑童话": `/home/styles/darkfairytale.webp?${thumbV}`,
|
||||||
|
"都市幻想": `/home/styles/urbanfantasy.webp?${thumbV}`,
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = requestAnimationFrame(() => setShown(true));
|
const id = requestAnimationFrame(() => setShown(true));
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
const closeRef = useRef<() => void>(null);
|
||||||
const close = () => {
|
const close = () => {
|
||||||
setShown(false);
|
setShown(false);
|
||||||
setTimeout(onClose, 280);
|
setTimeout(onClose, 280);
|
||||||
};
|
};
|
||||||
const startEditing = (i: number, currentPrompt: string) => {
|
closeRef.current = close;
|
||||||
setEditingIdx(i);
|
useEffect(() => {
|
||||||
setDraft(currentPrompt);
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") closeRef.current?.(); };
|
||||||
|
document.addEventListener("keydown", h);
|
||||||
|
return () => document.removeEventListener("keydown", h);
|
||||||
|
}, []);
|
||||||
|
const customIdx = items.indexOf("自定义风格");
|
||||||
|
const openCustomView = (prefill: string) => {
|
||||||
|
setDraft(prefill);
|
||||||
|
setView("custom");
|
||||||
};
|
};
|
||||||
const cancelEditing = () => {
|
const saveCustom = () => {
|
||||||
setEditingIdx(null);
|
|
||||||
setDraft("");
|
|
||||||
};
|
|
||||||
const saveEditing = () => {
|
|
||||||
if (editingIdx === null) return;
|
|
||||||
const targetName = items[editingIdx];
|
|
||||||
const t = draft.trim();
|
const t = draft.trim();
|
||||||
if (!targetName || !t) return;
|
if (!t) return;
|
||||||
if (targetName === "自定义") {
|
setCustomStyleGuide(t);
|
||||||
setCustomStyleGuide(t);
|
if (customIdx >= 0) onPick(customIdx);
|
||||||
} else {
|
|
||||||
// STYLE_MAP 这个 source-of-truth 不动;只往 in-memory overrides 写一条。
|
|
||||||
setStyleOverrides({ ...styleOverrides, [targetName]: t });
|
|
||||||
}
|
|
||||||
onPick(editingIdx);
|
|
||||||
setEditingIdx(null);
|
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
const resetOverride = (name: string) => {
|
|
||||||
const next = { ...styleOverrides };
|
|
||||||
delete next[name];
|
|
||||||
setStyleOverrides(next);
|
|
||||||
setDraft(STYLE_MAP[name] ?? "");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 客户端把上传的图片缩到 512px 长边 + webp(0.85),base64 通常落在 30-80KB。
|
|
||||||
// 必须客户端做:(1) 上传 / 后续 /api/scene 都会带这串,包不能太大;
|
|
||||||
// (2) Runware referenceImages 支持 base64,无需另外加 upload 端点。
|
|
||||||
const resizeImageToDataUrl = async (file: File): Promise<string> => {
|
const resizeImageToDataUrl = async (file: File): Promise<string> => {
|
||||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
const r = new FileReader();
|
const r = new FileReader();
|
||||||
@@ -955,7 +959,6 @@ function StyleModal({
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
||||||
ctx.drawImage(img, 0, 0, w, h);
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
// webp 比 jpeg 体积更小一些;浏览器全支持。降级到 jpeg 作为兜底。
|
|
||||||
let out = canvas.toDataURL("image/webp", 0.85);
|
let out = canvas.toDataURL("image/webp", 0.85);
|
||||||
if (!out.startsWith("data:image/webp")) {
|
if (!out.startsWith("data:image/webp")) {
|
||||||
out = canvas.toDataURL("image/jpeg", 0.85);
|
out = canvas.toDataURL("image/jpeg", 0.85);
|
||||||
@@ -982,8 +985,6 @@ function StyleModal({
|
|||||||
throw new Error(j.error ?? `${res.status}`);
|
throw new Error(j.error ?? `${res.status}`);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as { stylePrompt: string };
|
const data = (await res.json()) as { stylePrompt: string };
|
||||||
// 收到 AI 解析后的 prompt → 覆盖正在编辑的 draft + 持久化参考图。
|
|
||||||
// 用户事后还可以手动改 draft(仍是 textarea)。
|
|
||||||
setDraft(data.stylePrompt);
|
setDraft(data.stylePrompt);
|
||||||
setCustomStyleRefImage(resized);
|
setCustomStyleRefImage(resized);
|
||||||
track("style_image_upload", { ok: true });
|
track("style_image_upload", { ok: true });
|
||||||
@@ -1000,29 +1001,12 @@ function StyleModal({
|
|||||||
setCustomStyleRefImage("");
|
setCustomStyleRefImage("");
|
||||||
setParseError(null);
|
setParseError(null);
|
||||||
};
|
};
|
||||||
// 标题取去掉括号后缀的"主名"——括号里的英文 / 「Image N参考」之类的脚注
|
|
||||||
// 在标题位上显示噪声太大,挪到下方 prompt 行也已经覆盖到了。两种括号都
|
|
||||||
// 兼容(中文「()」和英文「()」)。
|
|
||||||
const stripSuffix = (s: string) => s.replace(/\s*[((].*?[))]\s*$/, "");
|
|
||||||
const q2 = q.trim();
|
const q2 = q.trim();
|
||||||
const list = items
|
const list = items.map((name, i) => ({ name, i })).filter((x) => {
|
||||||
.map((name, i) => {
|
if (!q2) return true;
|
||||||
const base = STYLE_MAP[name] ?? "";
|
return x.name.toLowerCase().includes(q2.toLowerCase());
|
||||||
const override = styleOverrides[name];
|
});
|
||||||
return {
|
|
||||||
name,
|
|
||||||
title: stripSuffix(name),
|
|
||||||
// 列表里展示的是「有效 prompt」——优先 override,让用户看到自己改过的版本
|
|
||||||
prompt: override ?? base,
|
|
||||||
hasOverride: typeof override === "string" && override.length > 0,
|
|
||||||
i,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((x) => {
|
|
||||||
if (!q2) return true;
|
|
||||||
const hay = (x.title + " " + x.name + " " + x.prompt).toLowerCase();
|
|
||||||
return hay.includes(q2.toLowerCase());
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onMouseDown={close}
|
onMouseDown={close}
|
||||||
@@ -1034,27 +1018,43 @@ function StyleModal({
|
|||||||
<div
|
<div
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
className={
|
className={
|
||||||
"flex w-[860px] max-w-[94vw] max-h-[86vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
|
"flex w-[1400px] max-w-[94vw] h-[86vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
|
||||||
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
|
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
|
||||||
<div className="flex flex-col">
|
{view === "custom" ? (
|
||||||
<span className="font-serif text-xl md:text-2xl text-clay-900">选择绘画风格</span>
|
<div className="flex flex-1 items-center gap-3">
|
||||||
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
<button
|
||||||
默认「自动」· 点 prompt 框旁的 ✎ 可在该风格基础上修改(默认 prompt 不会被覆盖)
|
type="button"
|
||||||
</span>
|
onClick={() => setView("grid")}
|
||||||
</div>
|
className="flex h-8 w-8 items-center justify-center rounded-sm text-clay-500 hover:bg-cream-100 hover:text-clay-900 transition-colors"
|
||||||
<div className="relative ml-auto w-[280px] max-w-[46vw]">
|
aria-label="返回"
|
||||||
<input
|
>
|
||||||
value={q}
|
<i className="fa-solid fa-arrow-left text-sm" />
|
||||||
onChange={(e) => setQ(e.target.value)}
|
</button>
|
||||||
placeholder="搜索风格 / prompt…"
|
<span className="font-serif text-xl md:text-2xl text-clay-900">自定义风格</span>
|
||||||
autoFocus
|
</div>
|
||||||
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-10 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
) : (
|
||||||
/>
|
<>
|
||||||
<i className="fa-solid fa-magnifying-glass absolute right-3.5 top-1/2 -translate-y-1/2 text-sm text-clay-400 pointer-events-none" />
|
<div className="flex flex-1 flex-col">
|
||||||
</div>
|
<span className="font-serif text-xl md:text-2xl text-clay-900">选择绘画风格</span>
|
||||||
|
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
||||||
|
默认「自动」· 由 AI 根据故事自动匹配画风;选择「自定义风格」可输入描述或上传参考图
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-[280px] max-w-[46vw]">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="搜索风格…"
|
||||||
|
autoFocus
|
||||||
|
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-10 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||||
|
/>
|
||||||
|
<i className="fa-solid fa-magnifying-glass absolute right-3.5 top-1/2 -translate-y-1/2 text-sm text-clay-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
@@ -1064,300 +1064,174 @@ function StyleModal({
|
|||||||
<i className="fa-solid fa-xmark" />
|
<i className="fa-solid fa-xmark" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 overflow-y-auto px-4 py-5 md:px-6 md:py-6">
|
|
||||||
{list.map(({ name, title, prompt, hasOverride, i }) => {
|
{view === "custom" ? (
|
||||||
const isCustom = name === "自定义";
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-6 md:px-8">
|
||||||
const selected = i === value;
|
<input
|
||||||
const editable = isCustom || Boolean(STYLE_MAP[name]);
|
ref={fileInputRef}
|
||||||
const isEditing = editingIdx === i;
|
type="file"
|
||||||
return (
|
accept="image/*"
|
||||||
<div
|
className="hidden"
|
||||||
key={i}
|
onChange={(e) => {
|
||||||
onClick={(e) => {
|
const f = e.target.files?.[0];
|
||||||
// 编辑态下:让点击事件落在 textarea/按钮上即可,不要冒泡触发"选中关闭"。
|
if (f) handleUploadStyleImage(f);
|
||||||
// 非编辑态下:点卡片选中此风格(自定义项点卡片直接进编辑)。
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
if (isEditing) return;
|
}}
|
||||||
const tag = (e.target as HTMLElement).tagName;
|
/>
|
||||||
if (tag === "BUTTON" || tag === "TEXTAREA" || tag === "I") return;
|
<textarea
|
||||||
if (isCustom) {
|
value={draft}
|
||||||
startEditing(i, customStyleGuide);
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
} else {
|
autoFocus
|
||||||
onPick(i);
|
rows={6}
|
||||||
close();
|
placeholder={"描述你想要的画面风格,例如:\n梦幻水彩风格,柔和的色调,怀旧的氛围\n\n💡 提示:部分绘图模型对英文提示词效果更佳,建议先借助 AI 对话工具生成专业的英文风格描述,再粘贴到这里"}
|
||||||
}
|
className="w-full flex-1 resize-y rounded-sm border border-clay-900/15 bg-cream-50 px-3 py-2.5 font-sans text-[13px] leading-relaxed text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||||
}}
|
/>
|
||||||
className={
|
{parseError && (
|
||||||
"flex items-start gap-4 rounded-sm border px-3 py-3 md:px-4 md:py-3.5 text-left transition-all " +
|
<span className="font-sans text-[11px] text-rose-500">
|
||||||
(isEditing
|
<i className="fa-solid fa-circle-exclamation mr-1" />
|
||||||
? "border-ember-500 bg-cream-50 cursor-default"
|
{parseError}
|
||||||
: selected
|
</span>
|
||||||
? "border-ember-500 bg-ember-500/5 cursor-pointer"
|
)}
|
||||||
: "border-clay-900/12 hover:border-clay-900/35 hover:bg-cream-100 cursor-pointer")
|
<div className="flex items-center gap-2">
|
||||||
}
|
{customStyleRefImage ? (
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<span
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
aria-hidden
|
<img
|
||||||
|
src={customStyleRefImage}
|
||||||
|
alt="画风参考图"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-sm border border-clay-900/10 object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={parsing}
|
||||||
|
className="font-sans text-[11px] text-clay-500 hover:text-ember-500 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
换一张
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeStyleRefImage()}
|
||||||
|
className="font-sans text-[11px] text-clay-400 hover:text-clay-900 transition-colors"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={parsing}
|
||||||
className={
|
className={
|
||||||
"flex h-12 w-12 shrink-0 items-center justify-center rounded-sm border text-base " +
|
"flex items-center gap-1.5 rounded-sm border px-3 py-1.5 font-sans text-[12px] transition-colors " +
|
||||||
(isCustom
|
(parsing
|
||||||
? "border-ember-500/40 bg-ember-500/10 text-ember-500"
|
? "border-clay-900/15 text-clay-400 cursor-wait"
|
||||||
: "border-clay-900/10 bg-cream-100 text-clay-400")
|
: "border-clay-900/15 text-clay-700 hover:border-ember-500 hover:text-ember-500")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<i
|
{parsing ? (
|
||||||
className={
|
<>
|
||||||
isCustom ? "fa-solid fa-pen-to-square" : "fa-regular fa-image"
|
<i className="fa-solid fa-circle-notch fa-spin text-[11px]" />
|
||||||
}
|
解析中…
|
||||||
/>
|
</>
|
||||||
</span>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
{/* 标题(标题永远不可编辑) */}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"font-serif text-base md:text-lg leading-snug flex items-center gap-2 " +
|
|
||||||
(selected || isEditing ? "text-ember-500" : "text-clay-900")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCustom ? "自定义 prompt" : title}
|
|
||||||
{hasOverride && !isEditing && (
|
|
||||||
<span
|
|
||||||
className="rounded-sm border border-ember-500/40 bg-ember-500/10 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ember-500"
|
|
||||||
title="你修改过这条 prompt(仅本次会话生效,默认 prompt 不变)"
|
|
||||||
>
|
|
||||||
已改
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isCustom && customStyleRefImage && !isEditing && (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 rounded-sm border border-ember-500/40 bg-ember-500/10 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ember-500"
|
|
||||||
title="参考图已附带——每一幕画师都会参考这张图"
|
|
||||||
>
|
|
||||||
<i className="fa-regular fa-image text-[9px]" />
|
|
||||||
附参考图
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 「自动」语义就是「让 AI 自己判断画风」,没有 prompt 可显示也无从编辑;
|
|
||||||
标题下方直接放一句解释,不渲染空文本框 / 铅笔。 */}
|
|
||||||
{name === "自动" ? (
|
|
||||||
<span className="font-sans text-[12px] md:text-[13px] leading-relaxed text-clay-500 mt-1">
|
|
||||||
由 AI 依据世界观自动选择合适画风(无需手动指定 prompt)
|
|
||||||
</span>
|
|
||||||
) : /* prompt 区域:非编辑态是看起来像文本框的只读容器;编辑态是真的 textarea */
|
|
||||||
isEditing ? (
|
|
||||||
<div className="mt-1.5 flex flex-col gap-2">
|
|
||||||
{/* 自定义卡专属:上传画风参考图。上传后会:(1) 用 vision LLM
|
|
||||||
解析成 prompt 覆盖到下方 textarea;(2) 图片本身随会话送到
|
|
||||||
画师,每幕都作为 reference 锚定画风。 */}
|
|
||||||
{isCustom && (
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const f = e.target.files?.[0];
|
|
||||||
if (f) handleUploadStyleImage(f);
|
|
||||||
// reset 让同一文件重选能再次触发 onChange
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{customStyleRefImage ? (
|
|
||||||
<div className="flex items-center gap-3 rounded-sm border border-clay-900/12 bg-cream-100 px-3 py-2.5">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={customStyleRefImage}
|
|
||||||
alt="画风参考图"
|
|
||||||
className="h-14 w-14 shrink-0 rounded-sm border border-clay-900/10 object-cover"
|
|
||||||
/>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<span className="font-sans text-[12px] text-clay-900">
|
|
||||||
<i className="fa-solid fa-check mr-1.5 text-ember-500" />
|
|
||||||
参考图已上传
|
|
||||||
</span>
|
|
||||||
<span className="font-sans text-[11px] leading-snug text-clay-500">
|
|
||||||
AI 已解析为下方 prompt;每一幕画师都会参考这张图
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}}
|
|
||||||
disabled={parsing}
|
|
||||||
className="font-sans text-[11px] text-clay-500 hover:text-ember-500 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
换一张
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeStyleRefImage();
|
|
||||||
}}
|
|
||||||
className="font-sans text-[11px] text-clay-400 hover:text-clay-900 transition-colors"
|
|
||||||
>
|
|
||||||
移除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}}
|
|
||||||
disabled={parsing}
|
|
||||||
className={
|
|
||||||
"flex items-center justify-center gap-2 rounded-sm border border-dashed px-3 py-2.5 font-sans text-[12px] transition-colors " +
|
|
||||||
(parsing
|
|
||||||
? "border-clay-900/15 bg-cream-100 text-clay-400 cursor-wait"
|
|
||||||
: "border-clay-900/25 text-clay-700 hover:border-ember-500 hover:bg-ember-500/5 hover:text-ember-500")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{parsing ? (
|
|
||||||
<>
|
|
||||||
<i className="fa-solid fa-circle-notch fa-spin text-[11px]" />
|
|
||||||
AI 正在解析参考图…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="fa-regular fa-image text-[13px]" />
|
|
||||||
上传画风参考图(可选)· AI 自动解析为 prompt
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{parseError && (
|
|
||||||
<span className="font-sans text-[11px] text-rose-500">
|
|
||||||
<i className="fa-solid fa-circle-exclamation mr-1" />
|
|
||||||
{parseError}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<textarea
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
autoFocus
|
|
||||||
rows={6}
|
|
||||||
placeholder={
|
|
||||||
isCustom
|
|
||||||
? "示例:\nA dreamy watercolor illustration, soft pastel washes, gentle line art, nostalgic atmosphere."
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
className="w-full resize-y rounded-sm border border-ember-500 bg-cream-50 px-3 py-2.5 font-sans text-[13px] leading-relaxed text-clay-900 outline-none placeholder:text-clay-400"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
{!isCustom && styleOverrides[name] ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
resetOverride(name);
|
|
||||||
}}
|
|
||||||
className="font-sans text-xs text-clay-500 hover:text-ember-500 transition-colors"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-rotate-left mr-1.5" />
|
|
||||||
还原默认 prompt
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
cancelEditing();
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 font-sans text-xs text-clay-500 hover:text-clay-900 transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!draft.trim()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
saveEditing();
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
"rounded-sm px-4 py-1.5 font-sans text-xs text-cream-50 transition-colors " +
|
|
||||||
(draft.trim()
|
|
||||||
? "bg-clay-900 hover:bg-ember-500"
|
|
||||||
: "bg-clay-300 cursor-not-allowed")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
保存并选用
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* 只读 prompt 行——无边框、纯文字,铅笔靠 padding-right 留位 */
|
<>
|
||||||
<div className="mt-1 relative">
|
<i className="fa-regular fa-image text-[11px]" />
|
||||||
<div
|
上传参考图
|
||||||
className={
|
</>
|
||||||
"pr-8 font-sans text-[12px] md:text-[13px] leading-relaxed line-clamp-2 " +
|
|
||||||
(isCustom && !customStyleGuide
|
|
||||||
? "italic text-clay-400"
|
|
||||||
: "text-clay-500")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCustom
|
|
||||||
? customStyleGuide || "点击此卡片或铅笔编辑你自己的画风 prompt"
|
|
||||||
: prompt || "(这个风格没有默认 prompt——点 ✎ 添加)"}
|
|
||||||
</div>
|
|
||||||
{editable && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
startEditing(
|
|
||||||
i,
|
|
||||||
isCustom
|
|
||||||
? customStyleGuide
|
|
||||||
: styleOverrides[name] ?? STYLE_MAP[name] ?? "",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
hasOverride
|
|
||||||
? "再次编辑此 prompt"
|
|
||||||
: "在此 prompt 基础上修改(默认 prompt 不会被覆盖)"
|
|
||||||
}
|
|
||||||
aria-label="编辑此风格 prompt"
|
|
||||||
className={
|
|
||||||
"absolute right-0 top-0 flex h-5 w-5 items-center justify-center rounded-sm text-[11px] transition-colors " +
|
|
||||||
(hasOverride
|
|
||||||
? "text-ember-500 hover:bg-ember-500/10"
|
|
||||||
: "text-clay-400 hover:bg-cream-100 hover:text-clay-700")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-pencil" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
)}
|
||||||
);
|
<select
|
||||||
})}
|
value=""
|
||||||
{list.length === 0 && (
|
onChange={(e) => {
|
||||||
<div className="py-12 text-center font-serif text-sm text-clay-400">
|
const v = e.target.value;
|
||||||
没有匹配的风格
|
if (v && STYLE_MAP[v]) setDraft(STYLE_MAP[v]);
|
||||||
|
}}
|
||||||
|
className="h-8 w-44 rounded-sm border border-clay-900/15 bg-cream-50 px-2 font-sans text-[12px] text-clay-700 outline-none transition-colors focus:border-ember-500"
|
||||||
|
>
|
||||||
|
<option value="">从预设风格导入…</option>
|
||||||
|
{Object.keys(STYLE_MAP).map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("grid")}
|
||||||
|
className="rounded-sm border border-clay-900/15 px-4 py-1.5 font-sans text-xs text-clay-700 hover:border-clay-900/30 hover:text-clay-900 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
onClick={saveCustom}
|
||||||
|
className={
|
||||||
|
"rounded-sm px-4 py-1.5 font-sans text-xs transition-colors " +
|
||||||
|
(draft.trim()
|
||||||
|
? "bg-clay-900 text-cream-50 hover:bg-ember-500"
|
||||||
|
: "bg-clay-900/20 text-clay-500 cursor-not-allowed")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
保存并选用
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 overflow-y-auto px-6 py-6 md:grid-cols-4 md:gap-4 md:px-8">
|
||||||
|
{list.map(({ name, i }) => {
|
||||||
|
const isCustom = name === "自定义风格";
|
||||||
|
const thumb = STYLE_THUMB[name];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (isCustom) {
|
||||||
|
openCustomView(customStyleGuide);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPick(i);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isCustom) { openCustomView(customStyleGuide); return; }
|
||||||
|
onPick(i);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
"group cursor-pointer rounded-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-ember-500 " +
|
||||||
|
(i === value
|
||||||
|
? "border-ember-500 ring-2 ring-ember-500"
|
||||||
|
: "border-clay-900/12 hover:border-ember-500/50 hover:ring-2 hover:ring-ember-500/25")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative w-full overflow-hidden" style={{ paddingBottom: "100%" }}>
|
||||||
|
{thumb ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img src={thumb} alt={name} loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-cream-100" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={"block px-2 py-2 text-center font-serif text-sm " + (i === value ? "text-ember-500" : "text-clay-700")}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{list.length === 0 && (
|
||||||
|
<div className="col-span-full py-12 text-center font-serif text-sm text-clay-400">
|
||||||
|
没有匹配的风格
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1372,28 +1246,22 @@ export default function HomePage() {
|
|||||||
const [open, setOpen] = useState<number>(-1);
|
const [open, setOpen] = useState<number>(-1);
|
||||||
const [styleOpen, setStyleOpen] = useState(false);
|
const [styleOpen, setStyleOpen] = useState(false);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
// 用户在「自定义」入口里填的 styleGuide 文本(中/英文都行,原样喂给 LLM)。
|
|
||||||
// 仅在内存里持有——刷新即丢,符合「这就是一次性试玩」的语义。
|
|
||||||
const [customStyleGuide, setCustomStyleGuide] = useState("");
|
const [customStyleGuide, setCustomStyleGuide] = useState("");
|
||||||
// 用户对某个预设的 prompt 改写——只覆盖该用户本次会话,绝不污染 STYLE_MAP
|
|
||||||
// 这个 source-of-truth。键是预设名(如 "京阿尼细腻日常"),值是 override prompt。
|
|
||||||
// 选中该预设 + 有 override → 把 override 当 styleGuide 喂给画师。
|
|
||||||
const [styleOverrides, setStyleOverrides] = useState<Record<string, string>>({});
|
|
||||||
// 用户在「自定义」里上传的参考图(已客户端缩到 512px、webp base64)。
|
|
||||||
// 同时随 sessionStorage 透传到 /play → /api/start → session → painter,
|
|
||||||
// 每一幕的 painter 都会把它作为 reference slot 0,锚定整局画风。
|
|
||||||
const [customStyleRefImage, setCustomStyleRefImage] = useState<string>("");
|
const [customStyleRefImage, setCustomStyleRefImage] = useState<string>("");
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
|
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
|
||||||
const [hintClosed, setHintClosed] = useState(false);
|
const [hintClosed, setHintClosed] = useState(false);
|
||||||
|
|
||||||
// 自带 TTS Key 弹窗:可选增强,Key 只存浏览器、绝不经过服务器。
|
// 统一设置弹窗(名字 + 识图 + TTS Key):可选增强,数据只存浏览器。
|
||||||
const [ttsOpen, setTtsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [ttsConfigured, setTtsConfigured] = useState(false);
|
const [ttsConfigured, setTtsConfigured] = useState(false);
|
||||||
|
const [playerName, setPlayerName] = useState("");
|
||||||
|
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
||||||
|
|
||||||
const styleRow = OPTS.findIndex((o) => o.modal);
|
const styleRow = OPTS.findIndex((o) => o.modal);
|
||||||
const voiceRow = OPTS.findIndex((o) => o.label === "语音配音");
|
const voiceRow = OPTS.findIndex((o) => o.label === "语音配音");
|
||||||
|
const paceRow = OPTS.findIndex((o) => o.label === "内容节奏");
|
||||||
const genderIndex = sel[0] ?? 0;
|
const genderIndex = sel[0] ?? 0;
|
||||||
const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向";
|
const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向";
|
||||||
const phrases = EXAMPLE_PHRASES[gender];
|
const phrases = EXAMPLE_PHRASES[gender];
|
||||||
@@ -1435,9 +1303,11 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 启动时回填「已启用」徽标——读 localStorage 判断用户是否已存过 Key。
|
// 启动时回填配置状态——读 localStorage 判断用户是否已存过 Key / 名字。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTtsConfigured(readStoredTtsConfig() != null);
|
setTtsConfigured(readStoredTtsConfig() != null);
|
||||||
|
setPlayerName(readStoredPlayerName());
|
||||||
|
setVisionClickEnabled(readStoredVisionClick());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。
|
// 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。
|
||||||
@@ -1464,8 +1334,9 @@ export default function HomePage() {
|
|||||||
prompt.trim() || (phrases[phraseIdx] ?? "").trim();
|
prompt.trim() || (phrases[phraseIdx] ?? "").trim();
|
||||||
const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动";
|
const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动";
|
||||||
const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折";
|
const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折";
|
||||||
const voice = OPTS[3]!.items[sel[3] ?? 1]!;
|
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
|
||||||
const pace = PACINGS[sel[4] ?? 1] ?? "紧凑爽快";
|
const audioEnabled = voice === "开启";
|
||||||
|
const pace = PACINGS[sel[paceRow] ?? 1] ?? "紧凑爽快";
|
||||||
|
|
||||||
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
|
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
|
||||||
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
|
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
|
||||||
@@ -1486,33 +1357,26 @@ export default function HomePage() {
|
|||||||
]
|
]
|
||||||
).join("\n");
|
).join("\n");
|
||||||
|
|
||||||
// 「自动」→ fall back to Galgame CG (project default). Plain prompts like
|
// 「自动」→ pass "auto" to the server; the engine will run a parallel
|
||||||
// "由模型自动判断画风" are not understood by FLUX — it just paints them
|
// LLM call to pick the best style based on the story prompt.
|
||||||
// literally, so we'd rather lock in a sensible default.
|
// 「自定义风格」→ 用用户在弹窗里填的原始 styleGuide,原样喂给 LLM;空内容时
|
||||||
// 「自定义」→ 用用户在弹窗里填的原始 styleGuide,原样喂给 LLM;空内容时
|
|
||||||
// 退化到默认(避免传入空字符串导致 /api/start 报缺字段)。
|
// 退化到默认(避免传入空字符串导致 /api/start 报缺字段)。
|
||||||
// TODO(自动路由): 后续实现真正的「自动」——由模型依据世界观 / 玩家 prompt
|
const DEFAULT_STYLE = "吉卜力";
|
||||||
// 选出最合适的画风,再映射到对应风格提示词,而非固定回退到 Galgame。届时
|
|
||||||
// 同步更新风格弹窗副标题(「由模型根据 prompt 判断风格」)使文案与行为一致。
|
|
||||||
const DEFAULT_STYLE = "Galgame CG";
|
|
||||||
let styleGuide: string;
|
let styleGuide: string;
|
||||||
if (artStyle === "自定义" && customStyleGuide.trim()) {
|
if (artStyle === "自动") {
|
||||||
|
styleGuide = "auto";
|
||||||
|
} else if (artStyle === "自定义风格" && customStyleGuide.trim()) {
|
||||||
styleGuide = customStyleGuide.trim();
|
styleGuide = customStyleGuide.trim();
|
||||||
} else if (styleOverrides[artStyle]?.trim()) {
|
|
||||||
// 用户对该预设做过 prompt 修改——优先用 override,不污染 STYLE_MAP。
|
|
||||||
styleGuide = styleOverrides[artStyle]!.trim();
|
|
||||||
} else {
|
} else {
|
||||||
const effectiveStyle =
|
const effectiveStyle =
|
||||||
artStyle === "自动" || artStyle === "自定义" ? DEFAULT_STYLE : artStyle;
|
artStyle === "自定义风格" ? DEFAULT_STYLE : artStyle;
|
||||||
styleGuide = STYLE_MAP[effectiveStyle] ?? STYLE_MAP[DEFAULT_STYLE]!;
|
styleGuide = STYLE_MAP[effectiveStyle] ?? STYLE_MAP[DEFAULT_STYLE]!;
|
||||||
}
|
}
|
||||||
const audioEnabled = voice === "开启";
|
|
||||||
|
|
||||||
// 只有「自定义」风格选中、且确实上传了参考图时才透传——其他预设没必要
|
// 只有「自定义」风格选中、且确实上传了参考图时才透传——其他预设没必要
|
||||||
// 占用 reference slot(也避免 styleGuide 已经是文本预设、画师收到不相关
|
// 占用 reference slot(也避免 styleGuide 已经是文本预设、画师收到不相关
|
||||||
// 参考图反而产生干扰)。
|
// 参考图反而产生干扰)。
|
||||||
const styleReferenceImage =
|
const styleReferenceImage =
|
||||||
artStyle === "自定义" && customStyleRefImage ? customStyleRefImage : undefined;
|
artStyle === "自定义风格" && customStyleRefImage ? customStyleRefImage : undefined;
|
||||||
|
|
||||||
track("game_start", {
|
track("game_start", {
|
||||||
source: "prompt",
|
source: "prompt",
|
||||||
@@ -1527,7 +1391,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"infiplot:custom",
|
"infiplot:custom",
|
||||||
JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage }),
|
JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage, playerName: playerName || undefined }),
|
||||||
);
|
);
|
||||||
router.push("/play?custom=1");
|
router.push("/play?custom=1");
|
||||||
};
|
};
|
||||||
@@ -1545,11 +1409,11 @@ export default function HomePage() {
|
|||||||
// 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」
|
// 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」
|
||||||
// 的红果默认基调,对精选卡不再生效。
|
// 的红果默认基调,对精选卡不再生效。
|
||||||
const onCardClick = (idx: number, _card: StoryContent) => {
|
const onCardClick = (idx: number, _card: StoryContent) => {
|
||||||
const voice = OPTS[3]!.items[sel[3] ?? 1]!;
|
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
|
||||||
const audioEnabled = voice === "开启";
|
const audioEnabled = voice === "开启";
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"infiplot:custom",
|
"infiplot:custom",
|
||||||
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled }),
|
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled, playerName }),
|
||||||
);
|
);
|
||||||
track("game_start", {
|
track("game_start", {
|
||||||
source: "curated",
|
source: "curated",
|
||||||
@@ -1568,6 +1432,15 @@ export default function HomePage() {
|
|||||||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
aria-label="设置"
|
||||||
|
title="设置"
|
||||||
|
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-gear" />
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/zonghaoyuan/infiplot"
|
href="https://github.com/zonghaoyuan/infiplot"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1610,7 +1483,7 @@ export default function HomePage() {
|
|||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
start();
|
start();
|
||||||
}
|
}
|
||||||
@@ -1638,6 +1511,11 @@ export default function HomePage() {
|
|||||||
<i className="fa-solid fa-arrow-right text-xs" />
|
<i className="fa-solid fa-arrow-right text-xs" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{prompt && (
|
||||||
|
<p className="mt-2 text-right text-xs text-clay-400">
|
||||||
|
Enter 发送 · Shift+Enter 换行
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* 类别选择器(居中) */}
|
{/* 类别选择器(居中) */}
|
||||||
@@ -1665,36 +1543,14 @@ export default function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 自带 TTS Key 入口:公共语音模型有 RPM/TPM 限额,高并发易静音;
|
|
||||||
填自己的小米 MiMo Key(免费)→ 稳定配音、延迟更低,且 Key 只存本地。 */}
|
|
||||||
<div className="mt-5 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTtsOpen(true)}
|
|
||||||
className={
|
|
||||||
"inline-flex items-center gap-2 rounded-full border px-4 py-1.5 font-sans text-xs md:text-[13px] transition-colors " +
|
|
||||||
(ttsConfigured
|
|
||||||
? "border-ember-500/40 bg-ember-500/5 text-ember-500 hover:bg-ember-500/10"
|
|
||||||
: "border-clay-900/15 text-clay-500 hover:border-clay-900/30 hover:text-clay-700")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={
|
|
||||||
ttsConfigured
|
|
||||||
? "fa-solid fa-circle-check text-[11px]"
|
|
||||||
: "fa-solid fa-microphone-lines text-[11px]"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{ttsConfigured ? "自带配音 Key · 已启用" : "经常没声音?自带配音 Key(可选)"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed) */}
|
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed) */}
|
||||||
{!hintClosed && (
|
{!hintClosed && (
|
||||||
<div className="relative mx-auto mt-10 md:mt-12 max-w-[640px] rounded-sm border border-clay-900/10 bg-cream-100/50 px-8 py-3.5">
|
<div className="relative mx-auto mt-10 md:mt-12 max-w-[640px] rounded-sm border border-clay-900/10 bg-cream-100/50 px-8 py-3.5">
|
||||||
<p className="font-serif text-[13px] md:text-sm leading-relaxed text-clay-500">
|
<p className="font-serif text-[13px] md:text-sm leading-relaxed text-clay-500">
|
||||||
输入你的想象、配置风格,点击「开始」即可游玩;也可以从下方的精选故事集,挑一篇快速体验{" "}
|
输入你的想象、配置风格,点击「开始」即可游玩;也可以从下方的精选故事集,挑一篇快速体验{" "}
|
||||||
<em className="not-italic text-ember-500">InfiPlot</em>。
|
<em className="not-italic text-ember-500">InfiPlot</em>。
|
||||||
|
点击「<span className="text-ember-500">设置</span>」可以配置你的名字和配音
|
||||||
|
API Key,让角色以你的名字称呼你,配音体验也更稳定。
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1750,7 +1606,7 @@ export default function HomePage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">团 队</p>
|
<p className="text-[10px] smallcaps text-clay-500 mb-3">团 队</p>
|
||||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
||||||
我们来自清华大学等高校,希望探索多模态模型在「直接生成图片、视频」这类 <span className="not-italic">one-shot</span> 能力之外,更多的可能性。本项目目前仍处于早期阶段,我们还在招募成员,如果你也感兴趣,欢迎联系我们,期待你的加入。
|
我们来自清华大学、兰州大学、西安交通大学等高校,希望探索多模态模型在「直接生成图片、视频」这类 <span className="not-italic">one-shot</span> 能力之外,更多的可能性。本项目目前仍处于早期阶段,我们还在招募成员,如果你也感兴趣,欢迎联系我们,期待你的加入。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1848,20 +1704,19 @@ export default function HomePage() {
|
|||||||
onClose={() => setStyleOpen(false)}
|
onClose={() => setStyleOpen(false)}
|
||||||
customStyleGuide={customStyleGuide}
|
customStyleGuide={customStyleGuide}
|
||||||
setCustomStyleGuide={setCustomStyleGuide}
|
setCustomStyleGuide={setCustomStyleGuide}
|
||||||
styleOverrides={styleOverrides}
|
|
||||||
setStyleOverrides={setStyleOverrides}
|
|
||||||
customStyleRefImage={customStyleRefImage}
|
customStyleRefImage={customStyleRefImage}
|
||||||
setCustomStyleRefImage={setCustomStyleRefImage}
|
setCustomStyleRefImage={setCustomStyleRefImage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{ttsOpen && (
|
{settingsOpen && (
|
||||||
<TtsKeyModal
|
<SettingsModal
|
||||||
onClose={() => setTtsOpen(false)}
|
initialVisionClickEnabled={visionClickEnabled}
|
||||||
onSaved={(configured) => {
|
onClose={() => setSettingsOpen(false)}
|
||||||
setTtsConfigured(configured);
|
onSaved={(settings) => {
|
||||||
// 启用自带 Key 时顺手把「语音配音」拨到「开启」——否则用户配了 Key
|
setTtsConfigured(settings.ttsConfigured);
|
||||||
// 却还是静音,体验自相矛盾。停用时不动其选择,尊重用户原本的偏好。
|
setPlayerName(settings.playerName);
|
||||||
if (configured && voiceRow >= 0) {
|
setVisionClickEnabled(settings.visionClickEnabled);
|
||||||
|
if (settings.ttsConfigured && voiceRow >= 0) {
|
||||||
const onIdx = OPTS[voiceRow]!.items.indexOf("开启");
|
const onIdx = OPTS[voiceRow]!.items.indexOf("开启");
|
||||||
if (onIdx >= 0)
|
if (onIdx >= 0)
|
||||||
setSel((s) => s.map((v, j) => (j === voiceRow ? onIdx : v)));
|
setSel((s) => s.map((v, j) => (j === voiceRow ? onIdx : v)));
|
||||||
|
|||||||
+463
-36
@@ -11,8 +11,13 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
|
import {
|
||||||
import { TtsKeyModal } from "@/components/TtsKeyModal";
|
PlayCanvas,
|
||||||
|
type Phase,
|
||||||
|
} from "@/components/PlayCanvas";
|
||||||
|
import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal";
|
||||||
|
import type { GalleryDoc, GalleryScene } from "@/app/gallery/page";
|
||||||
|
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||||
import { annotateClick } from "@/lib/annotateClient";
|
import { annotateClick } from "@/lib/annotateClient";
|
||||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||||
import { PRESETS } from "@/lib/presets";
|
import { PRESETS } from "@/lib/presets";
|
||||||
@@ -22,6 +27,7 @@ import type {
|
|||||||
BeatChoice,
|
BeatChoice,
|
||||||
Character,
|
Character,
|
||||||
CharacterVoice,
|
CharacterVoice,
|
||||||
|
FreeformClassifyResponse,
|
||||||
InsertBeatResponse,
|
InsertBeatResponse,
|
||||||
Orientation,
|
Orientation,
|
||||||
Scene,
|
Scene,
|
||||||
@@ -262,6 +268,58 @@ type ScenePathStep = {
|
|||||||
exit: { choiceId: string; label: string; nextSceneSeed: string };
|
exit: { choiceId: string; label: string; nextSceneSeed: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function buildDialogueHistory(
|
||||||
|
session: Session | null,
|
||||||
|
): DialogueHistoryItem[] {
|
||||||
|
if (!session) return [];
|
||||||
|
|
||||||
|
return session.history.flatMap((entry, sceneIndex) => {
|
||||||
|
const beatsById = new Map(entry.scene.beats.map((b) => [b.id, b]));
|
||||||
|
const visitedBeatIds = entry.visitedBeatIds;
|
||||||
|
|
||||||
|
return visitedBeatIds.flatMap((beatId, beatIndex) => {
|
||||||
|
const beat = beatsById.get(beatId);
|
||||||
|
if (!beat) return [];
|
||||||
|
|
||||||
|
const nextVisitedBeatId = visitedBeatIds[beatIndex + 1];
|
||||||
|
const choice =
|
||||||
|
beat.next.type === "choice"
|
||||||
|
? beat.next.choices.find((c) => {
|
||||||
|
if (c.effect.kind === "advance-beat") {
|
||||||
|
return c.effect.targetBeatId === nextVisitedBeatId;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
beatIndex === visitedBeatIds.length - 1 &&
|
||||||
|
entry.exit?.kind === "choice" &&
|
||||||
|
c.id === entry.exit.choiceId
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const freeformAction =
|
||||||
|
beatIndex === visitedBeatIds.length - 1 &&
|
||||||
|
entry.exit?.kind === "freeform"
|
||||||
|
? entry.exit.action
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const body = beat.speaker ? beat.line : beat.narration;
|
||||||
|
const narration = beat.speaker ? beat.narration : undefined;
|
||||||
|
if (!body && !narration && !choice && !freeformAction) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${sceneIndex}:${beatId}:${beatIndex}`,
|
||||||
|
sceneIndex: sceneIndex + 1,
|
||||||
|
speaker: beat.speaker,
|
||||||
|
body,
|
||||||
|
narration,
|
||||||
|
selectedChoice: choice?.label,
|
||||||
|
freeformAction,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function pathKey(steps: ScenePathStep[]): string {
|
function pathKey(steps: ScenePathStep[]): string {
|
||||||
return steps.map((s) => s.exit.choiceId).join("/");
|
return steps.map((s) => s.exit.choiceId).join("/");
|
||||||
}
|
}
|
||||||
@@ -311,6 +369,14 @@ function findSoleChangeSceneChoice(scene: Scene): BeatChoice | null {
|
|||||||
|
|
||||||
function prefetchScenePath(
|
function prefetchScenePath(
|
||||||
pool: Map<string, PrefetchEntry>,
|
pool: Map<string, PrefetchEntry>,
|
||||||
|
// Resolved-prefetch sink for the gallery export. Every successful resolve
|
||||||
|
// is recorded here keyed by `${parentSceneId}:${choiceId}` so the gallery
|
||||||
|
// can let the player click any choice whose alternate the AI already paid
|
||||||
|
// to generate — even ones that were later abandoned mid-play because the
|
||||||
|
// player took a different branch. Survives `consumeChoice`'s abort sweep:
|
||||||
|
// a prefetch that's already resolved when its parent choice is abandoned
|
||||||
|
// still leaves the result here.
|
||||||
|
resolvedSink: Map<string, Scene>,
|
||||||
baseSession: Session,
|
baseSession: Session,
|
||||||
steps: ScenePathStep[],
|
steps: ScenePathStep[],
|
||||||
depth: number,
|
depth: number,
|
||||||
@@ -337,6 +403,16 @@ function prefetchScenePath(
|
|||||||
}
|
}
|
||||||
const data = (await res.json()) as SceneResponse;
|
const data = (await res.json()) as SceneResponse;
|
||||||
|
|
||||||
|
// Record this resolved alternate for the gallery export. Key is
|
||||||
|
// (parent scene id at the choice point) : (choice id). Includes the
|
||||||
|
// CDN imageUrl on the Scene so the gallery has everything it needs to
|
||||||
|
// render without any further info from the engine.
|
||||||
|
const lastStep = steps[steps.length - 1]!;
|
||||||
|
resolvedSink.set(`${lastStep.fromScene.id}:${lastStep.exit.choiceId}`, {
|
||||||
|
...data.scene,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
// Kick off the blob fetch for this URL so when the player eventually
|
// Kick off the blob fetch for this URL so when the player eventually
|
||||||
// picks this choice, transitioning is a no-op cache lookup instead of a
|
// picks this choice, transitioning is a no-op cache lookup instead of a
|
||||||
// fresh CDN download. Don't await — let it run in the background; the
|
// fresh CDN download. Don't await — let it run in the background; the
|
||||||
@@ -375,6 +451,7 @@ function prefetchScenePath(
|
|||||||
};
|
};
|
||||||
prefetchScenePath(
|
prefetchScenePath(
|
||||||
pool,
|
pool,
|
||||||
|
resolvedSink,
|
||||||
carriedBase,
|
carriedBase,
|
||||||
[...steps, nextStep],
|
[...steps, nextStep],
|
||||||
depth + 1,
|
depth + 1,
|
||||||
@@ -502,12 +579,17 @@ function PlayInner() {
|
|||||||
const [silenceStrikes, setSilenceStrikes] = useState(0);
|
const [silenceStrikes, setSilenceStrikes] = useState(0);
|
||||||
// Once the player dismisses the silence nudge, keep it gone for this session.
|
// Once the player dismisses the silence nudge, keep it gone for this session.
|
||||||
const [nudgeDismissed, setNudgeDismissed] = useState(false);
|
const [nudgeDismissed, setNudgeDismissed] = useState(false);
|
||||||
// The in-place BYO-key modal, opened from the silence nudge so the player can
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
// add a key without leaving the play page.
|
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
||||||
const [ttsModalOpen, setTtsModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||||||
|
// Accumulator for resolved prefetches across the whole session — every
|
||||||
|
// `prefetchScenePath` resolution writes here, keyed by parent-scene + choice.
|
||||||
|
// Survives `consumeChoice`'s pool sweep (an already-resolved promise is not
|
||||||
|
// un-resolved by aborting its controller), so abandoned alternates remain
|
||||||
|
// available for the gallery export. Cleared only on unmount.
|
||||||
|
const resolvedPrefetchesRef = useRef<Map<string, Scene>>(new Map());
|
||||||
// Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene
|
// Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene
|
||||||
// changes so stale in-flight requests can't poison the new scene's map
|
// changes so stale in-flight requests can't poison the new scene's map
|
||||||
// (beat ids like "b1" are scene-local and would collide across scenes).
|
// (beat ids like "b1" are scene-local and would collide across scenes).
|
||||||
@@ -549,6 +631,11 @@ function PlayInner() {
|
|||||||
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
|
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
|
||||||
}, [currentScene, currentBeatId]);
|
}, [currentScene, currentBeatId]);
|
||||||
|
|
||||||
|
const dialogueHistory = useMemo<DialogueHistoryItem[]>(
|
||||||
|
() => buildDialogueHistory(session),
|
||||||
|
[session],
|
||||||
|
);
|
||||||
|
|
||||||
const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null;
|
const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -563,6 +650,9 @@ function PlayInner() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mutedRef.current = muted;
|
mutedRef.current = muted;
|
||||||
}, [muted]);
|
}, [muted]);
|
||||||
|
useEffect(() => {
|
||||||
|
setVisionClickEnabled(readStoredVisionClick());
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Coarse liveness ping for active-time analytics. /play is a single SPA
|
// Coarse liveness ping for active-time analytics. /play is a single SPA
|
||||||
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
|
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
|
||||||
@@ -763,15 +853,12 @@ function PlayInner() {
|
|||||||
prefetchSceneAudio();
|
prefetchSceneAudio();
|
||||||
}, [muted, prefetchSceneAudio]);
|
}, [muted, prefetchSceneAudio]);
|
||||||
|
|
||||||
// ── BYO key enabled/disabled from the play page (silence nudge → modal) ─
|
const handleSettingsSaved = useCallback(
|
||||||
// On enable: point the synth path at the user's key and immediately
|
(settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => {
|
||||||
// re-synthesize the current scene in-browser, so the voices the player just
|
setVisionClickEnabled(settings.visionClickEnabled);
|
||||||
// missed come back without a reload (their characters already carry
|
const nextPlayerName = settings.playerName || undefined;
|
||||||
// server-provisioned `voice`, which resolveByoVoice reuses with the new key).
|
setSession((prev) => prev ? { ...prev, playerName: nextPlayerName } : prev);
|
||||||
// On disable: just stop using it; later scenes fall back to the server.
|
const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null;
|
||||||
const handleByoSaved = useCallback(
|
|
||||||
(configured: boolean) => {
|
|
||||||
const cfg = configured ? loadClientTtsConfig() : null;
|
|
||||||
byoTtsRef.current = cfg;
|
byoTtsRef.current = cfg;
|
||||||
setByoTtsConfig(cfg);
|
setByoTtsConfig(cfg);
|
||||||
if (cfg) {
|
if (cfg) {
|
||||||
@@ -789,6 +876,164 @@ function PlayInner() {
|
|||||||
[prefetchSceneAudio],
|
[prefetchSceneAudio],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Export to interactive gallery (PPT-style replay) ─────────────────
|
||||||
|
// Drop all but the (keepCount) most-recent gallery exports from localStorage,
|
||||||
|
// ordered by their stored createdAt. Called right before writing a new
|
||||||
|
// export so the cap is enforced strictly (≤ keepCount + 1 transiently → ≤ N
|
||||||
|
// once write completes). Corrupt entries (un-parseable / no createdAt) sort
|
||||||
|
// last and get evicted first.
|
||||||
|
const trimGalleryExports = useCallback((keepCount: number) => {
|
||||||
|
try {
|
||||||
|
const prefix = "infiplot:gallery:";
|
||||||
|
const entries: { key: string; createdAt: number }[] = [];
|
||||||
|
for (let i = 0; i < window.localStorage.length; i++) {
|
||||||
|
const k = window.localStorage.key(i);
|
||||||
|
if (!k || !k.startsWith(prefix)) continue;
|
||||||
|
let createdAt = 0;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(k);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as { createdAt?: number };
|
||||||
|
createdAt = parsed.createdAt ?? 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
createdAt = 0;
|
||||||
|
}
|
||||||
|
entries.push({ key: k, createdAt });
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
for (const e of entries.slice(keepCount)) {
|
||||||
|
window.localStorage.removeItem(e.key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort — quota or disabled storage shouldn't block the export
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Strips the live Session to a small GalleryDoc — only scene images +
|
||||||
|
// dialogue text + recorded choices, no voice base64 / portraits / style
|
||||||
|
// reference (those are tens-to-hundreds of KB each). Writes it to
|
||||||
|
// localStorage under a one-shot id and opens /gallery#<id> in a new tab
|
||||||
|
// so the play session keeps running.
|
||||||
|
const handleExportGallery = useCallback(() => {
|
||||||
|
const s = sessionRef.current;
|
||||||
|
if (!s) return;
|
||||||
|
const scenes: GalleryScene[] = s.history
|
||||||
|
.map((h) => ({
|
||||||
|
id: h.scene.id,
|
||||||
|
imageUrl: h.scene.imageUrl ?? "",
|
||||||
|
sceneKey: h.scene.sceneKey,
|
||||||
|
orientation: h.scene.orientation,
|
||||||
|
beats: h.scene.beats,
|
||||||
|
entryBeatId: h.scene.entryBeatId,
|
||||||
|
visitedBeatIds: h.visitedBeatIds,
|
||||||
|
exit: h.exit,
|
||||||
|
}))
|
||||||
|
.filter((sc) => sc.imageUrl);
|
||||||
|
if (scenes.length === 0) return;
|
||||||
|
|
||||||
|
// Alternates: ${parentSceneId}:${choiceId} → reachable scene. Two sources,
|
||||||
|
// merged with main-path winning ties (it always agrees with prefetch when
|
||||||
|
// prefetch was actually used, so the override is a no-op in the common case;
|
||||||
|
// it differs only when the player took a cold path and the prefetch had
|
||||||
|
// resolved to something the engine later regenerated):
|
||||||
|
// 1. Every resolved prefetch (including alternates the player never took)
|
||||||
|
// 2. Main path: every history step's choice exit → the next visited scene
|
||||||
|
const alternates: Record<string, GalleryScene> = {};
|
||||||
|
for (const [key, scene] of resolvedPrefetchesRef.current) {
|
||||||
|
if (!scene.imageUrl) continue;
|
||||||
|
alternates[key] = {
|
||||||
|
id: scene.id,
|
||||||
|
imageUrl: scene.imageUrl,
|
||||||
|
sceneKey: scene.sceneKey,
|
||||||
|
orientation: scene.orientation,
|
||||||
|
beats: scene.beats,
|
||||||
|
entryBeatId: scene.entryBeatId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (let i = 0; i < s.history.length - 1; i++) {
|
||||||
|
const h = s.history[i]!;
|
||||||
|
const nextH = s.history[i + 1]!;
|
||||||
|
if (
|
||||||
|
h.exit?.kind === "choice" &&
|
||||||
|
h.scene.id &&
|
||||||
|
nextH.scene.imageUrl
|
||||||
|
) {
|
||||||
|
alternates[`${h.scene.id}:${h.exit.choiceId}`] = {
|
||||||
|
id: nextH.scene.id,
|
||||||
|
imageUrl: nextH.scene.imageUrl,
|
||||||
|
sceneKey: nextH.scene.sceneKey,
|
||||||
|
orientation: nextH.scene.orientation,
|
||||||
|
beats: nextH.scene.beats,
|
||||||
|
entryBeatId: nextH.scene.entryBeatId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character portraits — names + CDN URLs only. The big voice base64s are
|
||||||
|
// intentionally dropped (the gallery only needs the portraits for download).
|
||||||
|
const characters = s.characters
|
||||||
|
.filter((c) => c.basePortraitUrl)
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
basePortraitUrl: c.basePortraitUrl as string,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const id = `${Date.now().toString(36)}_${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
const doc: GalleryDoc = {
|
||||||
|
v: 2,
|
||||||
|
id,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
orientation: s.orientation ?? "landscape",
|
||||||
|
scenes,
|
||||||
|
alternates,
|
||||||
|
characters,
|
||||||
|
};
|
||||||
|
// Cap retained gallery exports at the most recent 2. Drop everything
|
||||||
|
// older BEFORE writing the new doc so we never transiently exceed the cap
|
||||||
|
// (and so a near-quota localStorage has headroom for the new entry).
|
||||||
|
trimGalleryExports(1);
|
||||||
|
const docStr = JSON.stringify(doc);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(`infiplot:gallery:${id}`, docStr);
|
||||||
|
} catch {
|
||||||
|
// localStorage full or disabled — silently bail; the player keeps playing.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
track("gallery_export", { scene_count: scenes.length });
|
||||||
|
window.open(`/gallery#id=${id}`, "_blank", "noopener");
|
||||||
|
|
||||||
|
// Fire-and-forget: also pack an encrypted `.infiplot` share file for the
|
||||||
|
// player to send to a friend. The local-tab view above is instant either
|
||||||
|
// way; this happens in the background. Server returns 503 if
|
||||||
|
// GALLERY_SECRET isn't configured, in which case we silently skip — the
|
||||||
|
// local view still works, just no share file.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/gallery-pack", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ docStr }),
|
||||||
|
});
|
||||||
|
if (!r.ok) return;
|
||||||
|
const blob = await r.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `infiplot-${id}.infiplot`;
|
||||||
|
a.rel = "noopener";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||||
|
} catch {
|
||||||
|
// network / decrypt error — local view above already worked
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [trimGalleryExports]);
|
||||||
|
|
||||||
// ── Presentation mode toggle ─────────────────────────────────────────
|
// ── Presentation mode toggle ─────────────────────────────────────────
|
||||||
const togglePresentation = useCallback(async () => {
|
const togglePresentation = useCallback(async () => {
|
||||||
const entering = !presentation;
|
const entering = !presentation;
|
||||||
@@ -836,11 +1081,10 @@ function PlayInner() {
|
|||||||
// Lock the visible orientation BEFORE the first paint, so portrait phones
|
// Lock the visible orientation BEFORE the first paint, so portrait phones
|
||||||
// never flash the landscape loading chrome. The state inits to "landscape"
|
// never flash the landscape loading chrome. The state inits to "landscape"
|
||||||
// for SSR-safety; this corrects it pre-paint (no-op re-render on landscape
|
// for SSR-safety; this corrects it pre-paint (no-op re-render on landscape
|
||||||
// devices). Prebaked cards (decision C) stay landscape-baked regardless of
|
// devices). The bootstrap effect below re-derives the same value for the
|
||||||
// device. The bootstrap effect below re-derives the same value for the
|
|
||||||
// /api/start payload.
|
// /api/start payload.
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
setOrientation(params.get("card") ? "landscape" : detectOrientation());
|
setOrientation(detectOrientation());
|
||||||
}, [params]);
|
}, [params]);
|
||||||
|
|
||||||
// ── Bootstrap: start session ─────────────────────────────────────────
|
// ── Bootstrap: start session ─────────────────────────────────────────
|
||||||
@@ -863,11 +1107,12 @@ function PlayInner() {
|
|||||||
styleGuide: string;
|
styleGuide: string;
|
||||||
styleReferenceImage?: string;
|
styleReferenceImage?: string;
|
||||||
orientation?: Orientation;
|
orientation?: Orientation;
|
||||||
|
playerName?: string;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
if (!cardName) {
|
if (!cardName) {
|
||||||
if (presetId) {
|
if (presetId) {
|
||||||
const p = PRESETS.find((x) => x.id === presetId);
|
const p = PRESETS.find((x) => x.id === presetId);
|
||||||
if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
|
if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide, playerName: readStoredPlayerName() || undefined };
|
||||||
} else if (isCustom) {
|
} else if (isCustom) {
|
||||||
const stored = sessionStorage.getItem("infiplot:custom");
|
const stored = sessionStorage.getItem("infiplot:custom");
|
||||||
if (stored) {
|
if (stored) {
|
||||||
@@ -877,11 +1122,13 @@ function PlayInner() {
|
|||||||
styleGuide: string;
|
styleGuide: string;
|
||||||
audioEnabled?: boolean;
|
audioEnabled?: boolean;
|
||||||
styleReferenceImage?: string;
|
styleReferenceImage?: string;
|
||||||
|
playerName?: string;
|
||||||
};
|
};
|
||||||
livePayload = {
|
livePayload = {
|
||||||
worldSetting: parsed.worldSetting,
|
worldSetting: parsed.worldSetting,
|
||||||
styleGuide: parsed.styleGuide,
|
styleGuide: parsed.styleGuide,
|
||||||
styleReferenceImage: parsed.styleReferenceImage || undefined,
|
styleReferenceImage: parsed.styleReferenceImage || undefined,
|
||||||
|
playerName: parsed.playerName || undefined,
|
||||||
};
|
};
|
||||||
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
|
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
|
||||||
} catch {
|
} catch {
|
||||||
@@ -891,14 +1138,10 @@ function PlayInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock orientation for the whole session. Prebaked cards (decision C) are
|
// Lock orientation for the whole session. Both prebaked-card and live paths
|
||||||
// landscape-baked, so they stay landscape regardless of device; only the
|
// now respect device orientation — portrait prebaked assets live under
|
||||||
// live /api/start path requests a portrait paint when the phone is upright.
|
// firstact-portrait/ and firstscene-portrait/.
|
||||||
// The visible state is already set pre-paint by the layout effect above;
|
const sessionOrientation: Orientation = detectOrientation();
|
||||||
// here we only need the value for the /api/start payload.
|
|
||||||
const sessionOrientation: Orientation = cardName
|
|
||||||
? "landscape"
|
|
||||||
: detectOrientation();
|
|
||||||
if (livePayload) livePayload.orientation = sessionOrientation;
|
if (livePayload) livePayload.orientation = sessionOrientation;
|
||||||
|
|
||||||
if (!cardName && !livePayload) {
|
if (!cardName && !livePayload) {
|
||||||
@@ -919,11 +1162,23 @@ function PlayInner() {
|
|||||||
cardGender?: string;
|
cardGender?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const firstactDir = sessionOrientation === "portrait"
|
||||||
|
? "firstact-portrait"
|
||||||
|
: "firstact";
|
||||||
|
|
||||||
const fetchStart: Promise<PrebakedFirstAct> = cardName
|
const fetchStart: Promise<PrebakedFirstAct> = cardName
|
||||||
? fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`).then(
|
? fetch(`/home/${firstactDir}/${encodeURIComponent(cardName)}.json`).then(
|
||||||
async (r) => {
|
async (r) => {
|
||||||
if (!r.ok) throw new Error(`找不到精选剧情:${cardName}`);
|
if (r.ok) return (await r.json()) as PrebakedFirstAct;
|
||||||
return (await r.json()) as PrebakedFirstAct;
|
if (sessionOrientation === "portrait") {
|
||||||
|
console.warn(`[play] portrait firstact missing for ${cardName} (HTTP ${r.status}), falling back to landscape`);
|
||||||
|
const fb = await fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`);
|
||||||
|
if (fb.ok) {
|
||||||
|
const fallback = (await fb.json()) as PrebakedFirstAct;
|
||||||
|
return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`找不到精选剧情:${cardName}`);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: fetch("/api/start", {
|
: fetch("/api/start", {
|
||||||
@@ -975,6 +1230,7 @@ function PlayInner() {
|
|||||||
storyState: data.storyState,
|
storyState: data.storyState,
|
||||||
styleReferenceImage: data.styleReferenceImage,
|
styleReferenceImage: data.styleReferenceImage,
|
||||||
orientation: data.scene.orientation ?? sessionOrientation,
|
orientation: data.scene.orientation ?? sessionOrientation,
|
||||||
|
playerName: livePayload?.playerName || readStoredPlayerName() || undefined,
|
||||||
};
|
};
|
||||||
visitedBeatsRef.current = [data.scene.entryBeatId];
|
visitedBeatsRef.current = [data.scene.entryBeatId];
|
||||||
setSession(initial);
|
setSession(initial);
|
||||||
@@ -1008,7 +1264,14 @@ function PlayInner() {
|
|||||||
nextSceneSeed: choice.effect.nextSceneSeed,
|
nextSceneSeed: choice.effect.nextSceneSeed,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
prefetchScenePath(poolRef.current, s, [step], 0, !!byoTtsRef.current);
|
prefetchScenePath(
|
||||||
|
poolRef.current,
|
||||||
|
resolvedPrefetchesRef.current,
|
||||||
|
s,
|
||||||
|
[step],
|
||||||
|
0,
|
||||||
|
!!byoTtsRef.current,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [currentScene?.id, session?.id]);
|
}, [currentScene?.id, session?.id]);
|
||||||
|
|
||||||
@@ -1180,6 +1443,137 @@ function PlayInner() {
|
|||||||
void performSceneTransition(promise, exit, visited, choice.label);
|
void performSceneTransition(promise, exit, visited, choice.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onFreeformInput(text: string) {
|
||||||
|
if (phase !== "ready" || !session || !currentScene) return;
|
||||||
|
|
||||||
|
track("freeform_input", {
|
||||||
|
scene_index: session.history.length,
|
||||||
|
text_length: text.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPhase("vision-thinking");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const classifyRes = await fetch("/api/classify-freeform", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session: stripVoicesForTransport(session),
|
||||||
|
freeformText: text,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!classifyRes.ok) {
|
||||||
|
const j = (await classifyRes.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(j.error ?? classifyRes.statusText);
|
||||||
|
}
|
||||||
|
const decision = (await classifyRes.json()) as FreeformClassifyResponse;
|
||||||
|
|
||||||
|
if (decision.classify === "insert-beat") {
|
||||||
|
// Interactive beat: NPC responds to the player's action, scene stays
|
||||||
|
setPhase("inserting-beat");
|
||||||
|
const insertRes = await fetch("/api/insert-beat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session: stripVoicesForTransport(session),
|
||||||
|
freeformAction: decision.freeformAction,
|
||||||
|
clientTts: !!byoTtsRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!insertRes.ok) {
|
||||||
|
const j = (await insertRes.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(j.error ?? insertRes.statusText);
|
||||||
|
}
|
||||||
|
const { partial, characters: insertChars } =
|
||||||
|
(await insertRes.json()) as InsertBeatResponse;
|
||||||
|
|
||||||
|
const fromBeatId =
|
||||||
|
currentBeatRef.current?.id ?? currentScene.entryBeatId;
|
||||||
|
const newBeatId = `b_ins_${Date.now()}_${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 6)}`;
|
||||||
|
const newBeat: Beat = {
|
||||||
|
id: newBeatId,
|
||||||
|
narration: partial.narration,
|
||||||
|
speaker: partial.speaker,
|
||||||
|
line: partial.line,
|
||||||
|
lineDelivery: partial.lineDelivery,
|
||||||
|
next: { type: "continue", nextBeatId: fromBeatId },
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched: Scene = {
|
||||||
|
...currentScene,
|
||||||
|
beats: [...currentScene.beats, newBeat],
|
||||||
|
};
|
||||||
|
const nextVisited = [...visitedBeatsRef.current, newBeatId];
|
||||||
|
visitedBeatsRef.current = nextVisited;
|
||||||
|
const nextSession: Session = {
|
||||||
|
...session,
|
||||||
|
history: session.history.map((h, i, arr) =>
|
||||||
|
i === arr.length - 1 ? { ...h, scene: patched, visitedBeatIds: nextVisited } : h,
|
||||||
|
),
|
||||||
|
characters: mergeCharactersPreserveVoice(
|
||||||
|
session.characters,
|
||||||
|
insertChars,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
setSession(nextSession);
|
||||||
|
setCurrentScene(patched);
|
||||||
|
setCurrentBeatId(newBeatId);
|
||||||
|
if (newBeat.speaker && newBeat.line) {
|
||||||
|
void fetchBeatAudio(nextSession, {
|
||||||
|
id: newBeatId,
|
||||||
|
speaker: newBeat.speaker,
|
||||||
|
line: newBeat.line,
|
||||||
|
lineDelivery: newBeat.lineDelivery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLastExitLabel(decision.freeformAction);
|
||||||
|
setPhase("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// change-scene path
|
||||||
|
const visited = [...visitedBeatsRef.current];
|
||||||
|
const exit: SceneExit = {
|
||||||
|
kind: "freeform",
|
||||||
|
action: decision.freeformAction,
|
||||||
|
};
|
||||||
|
clearPool(poolRef.current);
|
||||||
|
|
||||||
|
const specSession: Session = {
|
||||||
|
...session,
|
||||||
|
history: session.history.map((h, i, arr) =>
|
||||||
|
i === arr.length - 1
|
||||||
|
? { ...h, visitedBeatIds: visited, exit }
|
||||||
|
: h,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const res = await fetch("/api/scene", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session: stripVoicesForTransport(specSession),
|
||||||
|
clientTts: !!byoTtsRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(j.error ?? res.statusText);
|
||||||
|
}
|
||||||
|
return (await res.json()) as SceneResponse;
|
||||||
|
})();
|
||||||
|
|
||||||
|
setPendingClick(null);
|
||||||
|
void performSceneTransition(promise, exit, visited, decision.freeformAction);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setPhase("ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onBackgroundClick(click: { x: number; y: number }) {
|
async function onBackgroundClick(click: { x: number; y: number }) {
|
||||||
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
|
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
|
||||||
setPhase("vision-thinking");
|
setPhase("vision-thinking");
|
||||||
@@ -1367,8 +1761,13 @@ function PlayInner() {
|
|||||||
onBackgroundClick={onBackgroundClick}
|
onBackgroundClick={onBackgroundClick}
|
||||||
onAdvance={onAdvance}
|
onAdvance={onAdvance}
|
||||||
onSelectChoice={onSelectChoice}
|
onSelectChoice={onSelectChoice}
|
||||||
|
onFreeformInput={onFreeformInput}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
|
playerName={session?.playerName}
|
||||||
|
visionClickEnabled={visionClickEnabled}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
fullViewport
|
fullViewport
|
||||||
|
dialogueHistory={dialogueHistory}
|
||||||
/>
|
/>
|
||||||
{orientation === "portrait" && (
|
{orientation === "portrait" && (
|
||||||
<div
|
<div
|
||||||
@@ -1394,6 +1793,14 @@ function PlayInner() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{settingsOpen && (
|
||||||
|
<SettingsModal
|
||||||
|
initialVisionClickEnabled={visionClickEnabled}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onSaved={handleSettingsSaved}
|
||||||
|
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1441,7 +1848,12 @@ function PlayInner() {
|
|||||||
onBackgroundClick={onBackgroundClick}
|
onBackgroundClick={onBackgroundClick}
|
||||||
onAdvance={onAdvance}
|
onAdvance={onAdvance}
|
||||||
onSelectChoice={onSelectChoice}
|
onSelectChoice={onSelectChoice}
|
||||||
|
onFreeformInput={onFreeformInput}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
|
playerName={session?.playerName}
|
||||||
|
visionClickEnabled={visionClickEnabled}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
dialogueHistory={dialogueHistory}
|
||||||
aboveCanvas={
|
aboveCanvas={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1454,6 +1866,20 @@ function PlayInner() {
|
|||||||
F · 键 · 全 · 屏
|
F · 键 · 全 · 屏
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
belowCanvas={
|
||||||
|
session && session.history.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportGallery}
|
||||||
|
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||||||
|
aria-label="导出可交互图集"
|
||||||
|
title="导出本局为可交互图集链接(只会保留最近两次的可交互图集链接)"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-link text-[10px]" />
|
||||||
|
导 · 出 · 图 · 集
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
aboveCanvasLeft={
|
aboveCanvasLeft={
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1476,7 +1902,7 @@ function PlayInner() {
|
|||||||
<span className="flex items-center gap-1 animate-fade-in">
|
<span className="flex items-center gap-1 animate-fade-in">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTtsModalOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-ember-500/40 bg-ember-500/10 px-2.5 py-1 text-[10px] text-ember-500 hover:bg-ember-500/20 transition-colors"
|
className="inline-flex items-center gap-1.5 rounded-full border border-ember-500/40 bg-ember-500/10 px-2.5 py-1 text-[10px] text-ember-500 hover:bg-ember-500/20 transition-colors"
|
||||||
title="经常没声音?填入你自己的小米 MiMo Key(免费),配音更稳定"
|
title="经常没声音?填入你自己的小米 MiMo Key(免费),配音更稳定"
|
||||||
>
|
>
|
||||||
@@ -1514,11 +1940,12 @@ function PlayInner() {
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{ttsModalOpen && (
|
{settingsOpen && (
|
||||||
<TtsKeyModal
|
<SettingsModal
|
||||||
onClose={() => setTtsModalOpen(false)}
|
initialVisionClickEnabled={visionClickEnabled}
|
||||||
onSaved={handleByoSaved}
|
onClose={() => setSettingsOpen(false)}
|
||||||
footerNote="保存后会立即用这把 Key 在你的浏览器里合成当前这一幕的配音;本设备后续游玩也会自动使用此 Key。"
|
onSaved={handleSettingsSaved}
|
||||||
|
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export type DialogueHistoryItem = {
|
||||||
|
id: string;
|
||||||
|
sceneIndex: number;
|
||||||
|
speaker?: string;
|
||||||
|
body?: string;
|
||||||
|
narration?: string;
|
||||||
|
selectedChoice?: string;
|
||||||
|
freeformAction?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DialogueHistoryModal({
|
||||||
|
items,
|
||||||
|
portrait,
|
||||||
|
onClose,
|
||||||
|
playerName,
|
||||||
|
}: {
|
||||||
|
items: DialogueHistoryItem[];
|
||||||
|
portrait: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
playerName?: string;
|
||||||
|
}) {
|
||||||
|
const displaySpeaker = (s: string | undefined) =>
|
||||||
|
s === "你" && playerName ? playerName : s;
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}, [items.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-20 flex items-center justify-center px-4 py-6 pointer-events-auto"
|
||||||
|
style={{ background: "rgba(0,0,0,0.38)" }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full ${
|
||||||
|
portrait ? "max-w-[92vw]" : "max-w-2xl"
|
||||||
|
} max-h-[72dvh] overflow-hidden`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: "rgba(14, 10, 6, 0.88)",
|
||||||
|
border: "1.5px solid rgba(175, 138, 72, 0.72)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backdropFilter: "blur(14px)",
|
||||||
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
|
boxShadow:
|
||||||
|
"0 10px 42px rgba(0,0,0,0.62), inset 0 1px 0 rgba(200,165,90,0.12)",
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="剧情回溯"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-cream-50/10 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] smallcaps text-cream-50/70">
|
||||||
|
<i className="fa-solid fa-clock-rotate-left text-[10px]" />
|
||||||
|
剧 · 情 · 回 · 溯
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-7 w-7 items-center justify-center text-cream-50/60 transition-colors hover:text-cream-50"
|
||||||
|
aria-label="关闭剧情回溯"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-xmark text-[12px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className={`vn-scrollbar overflow-y-auto px-4 py-3 ${
|
||||||
|
portrait ? "max-h-[58dvh]" : "max-h-[60dvh]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-8 text-center font-serif text-[13px] text-cream-50/55">
|
||||||
|
暂无历史。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="text-left">
|
||||||
|
<div className="mb-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-[9px] smallcaps text-cream-50/35">
|
||||||
|
第 {String(item.sceneIndex).padStart(3, "0")} 幕
|
||||||
|
</span>
|
||||||
|
{item.speaker && (
|
||||||
|
<span className="font-serif text-[12px] text-[rgba(205,165,90,0.92)]">
|
||||||
|
{displaySpeaker(item.speaker)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.body && (
|
||||||
|
<p
|
||||||
|
className={`font-serif leading-[1.75] ${
|
||||||
|
portrait ? "text-[15px]" : "text-[13px]"
|
||||||
|
}`}
|
||||||
|
style={{ color: "rgba(245,235,210,0.94)" }}
|
||||||
|
>
|
||||||
|
{item.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.narration && (
|
||||||
|
<p
|
||||||
|
className={`mt-1 font-serif italic leading-[1.65] ${
|
||||||
|
portrait ? "text-[13px]" : "text-[12px]"
|
||||||
|
}`}
|
||||||
|
style={{ color: "rgba(200,185,155,0.72)" }}
|
||||||
|
>
|
||||||
|
{item.narration}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.selectedChoice && (
|
||||||
|
<p className="mt-2 inline-flex max-w-full items-start gap-2 rounded-[5px] border border-[rgba(180,140,80,0.35)] bg-[rgba(180,140,60,0.10)] px-2.5 py-1.5 font-serif text-[12px] leading-snug text-cream-50/85">
|
||||||
|
<span className="shrink-0 text-[rgba(195,155,75,0.9)]">
|
||||||
|
选择
|
||||||
|
</span>
|
||||||
|
<span>{item.selectedChoice}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.freeformAction && (
|
||||||
|
<p className="mt-2 inline-flex max-w-full items-start gap-2 rounded-[5px] border border-ember-500/30 bg-ember-500/10 px-2.5 py-1.5 font-serif text-[12px] leading-snug text-cream-50/85">
|
||||||
|
<span className="shrink-0 text-ember-300/90">
|
||||||
|
行动
|
||||||
|
</span>
|
||||||
|
<span>{item.freeformAction}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+211
-32
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
DialogueHistoryModal,
|
||||||
|
type DialogueHistoryItem,
|
||||||
|
} from "@/components/DialogueHistoryModal";
|
||||||
import type { Beat, BeatChoice, Orientation } from "@infiplot/types";
|
import type { Beat, BeatChoice, Orientation } from "@infiplot/types";
|
||||||
|
|
||||||
export type Phase =
|
export type Phase =
|
||||||
@@ -170,10 +174,16 @@ export function PlayCanvas({
|
|||||||
onBackgroundClick,
|
onBackgroundClick,
|
||||||
onAdvance,
|
onAdvance,
|
||||||
onSelectChoice,
|
onSelectChoice,
|
||||||
|
onFreeformInput,
|
||||||
fullViewport = false,
|
fullViewport = false,
|
||||||
orientation = "landscape",
|
orientation = "landscape",
|
||||||
|
playerName,
|
||||||
|
visionClickEnabled = true,
|
||||||
|
onOpenSettings,
|
||||||
aboveCanvas,
|
aboveCanvas,
|
||||||
aboveCanvasLeft,
|
aboveCanvasLeft,
|
||||||
|
belowCanvas,
|
||||||
|
dialogueHistory = [],
|
||||||
}: {
|
}: {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
audioSrc: string | null;
|
audioSrc: string | null;
|
||||||
@@ -184,16 +194,30 @@ export function PlayCanvas({
|
|||||||
onBackgroundClick: (click: { x: number; y: number }) => void;
|
onBackgroundClick: (click: { x: number; y: number }) => void;
|
||||||
onAdvance: () => void;
|
onAdvance: () => void;
|
||||||
onSelectChoice: (choice: BeatChoice) => void;
|
onSelectChoice: (choice: BeatChoice) => void;
|
||||||
|
onFreeformInput?: (text: string) => void;
|
||||||
fullViewport?: boolean;
|
fullViewport?: boolean;
|
||||||
// 会话锁定的图片朝向。"portrait" 时整图铺满视口(object-fit:cover)、选项竖排、字号放大。
|
// 会话锁定的图片朝向。"portrait" 时整图铺满视口(object-fit:cover)、选项竖排、字号放大。
|
||||||
orientation?: Orientation;
|
orientation?: Orientation;
|
||||||
|
playerName?: string;
|
||||||
|
// 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。
|
||||||
|
visionClickEnabled?: boolean;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
||||||
aboveCanvas?: ReactNode;
|
aboveCanvas?: ReactNode;
|
||||||
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||||||
aboveCanvasLeft?: ReactNode;
|
aboveCanvasLeft?: ReactNode;
|
||||||
|
// 渲染在图片正下方、右对齐的 slot(画面外、紧贴右下角),与 aboveCanvas 垂直镜像。
|
||||||
|
belowCanvas?: ReactNode;
|
||||||
|
dialogueHistory?: DialogueHistoryItem[];
|
||||||
}) {
|
}) {
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [freeformOpen, setFreeformOpen] = useState(false);
|
||||||
|
const [freeformText, setFreeformText] = useState("");
|
||||||
|
const freeformInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const displaySpeaker = (s: string | undefined) =>
|
||||||
|
s === "你" && playerName ? playerName : s;
|
||||||
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
|
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -257,14 +281,18 @@ export function PlayCanvas({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleImageClick(e: React.MouseEvent<HTMLImageElement>) {
|
function handleImageClick(e: React.MouseEvent<HTMLImageElement>) {
|
||||||
if (phase !== "ready" || !imgRef.current || !beat) return;
|
if (phase !== "ready" || !beat) return;
|
||||||
|
if (!typingDone) {
|
||||||
|
skipTypewriter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (beat.next.type === "continue") {
|
||||||
|
onAdvance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!visionClickEnabled || !imgRef.current) return;
|
||||||
const el = imgRef.current;
|
const el = imgRef.current;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
// Portrait renders with object-fit:cover, which scales the 9:16 image to
|
|
||||||
// FILL the box and crops the overflow — so the rendered box ≠ the full
|
|
||||||
// image. Map the click from box-space back into full-image-space via the
|
|
||||||
// cover geometry so the marker lands where the user tapped. Landscape's box
|
|
||||||
// matches the image aspect (no crop), so it keeps simple normalization.
|
|
||||||
let x: number;
|
let x: number;
|
||||||
let y: number;
|
let y: number;
|
||||||
if (orientation === "portrait") {
|
if (orientation === "portrait") {
|
||||||
@@ -279,18 +307,6 @@ export function PlayCanvas({
|
|||||||
x = (e.clientX - rect.left) / rect.width;
|
x = (e.clientX - rect.left) / rect.width;
|
||||||
y = (e.clientY - rect.top) / rect.height;
|
y = (e.clientY - rect.top) / rect.height;
|
||||||
}
|
}
|
||||||
// If the typewriter is still printing, a click completes it instantly
|
|
||||||
// (standard VN affordance) — the page never sees this click.
|
|
||||||
if (!typingDone) {
|
|
||||||
skipTypewriter();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// For continue-type beats, image click advances; for choice beats,
|
|
||||||
// image click goes through vision (treat as freeform action).
|
|
||||||
if (beat.next.type === "continue") {
|
|
||||||
onAdvance();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onBackgroundClick({
|
onBackgroundClick({
|
||||||
x: Math.max(0, Math.min(1, x)),
|
x: Math.max(0, Math.min(1, x)),
|
||||||
y: Math.max(0, Math.min(1, y)),
|
y: Math.max(0, Math.min(1, y)),
|
||||||
@@ -310,6 +326,9 @@ export function PlayCanvas({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const interactive = phase === "ready" && !!imageUrl;
|
const interactive = phase === "ready" && !!imageUrl;
|
||||||
|
const imageClickable =
|
||||||
|
interactive &&
|
||||||
|
(!typingDone || beat?.next.type === "continue" || visionClickEnabled);
|
||||||
const dimmed = phase === "transitioning";
|
const dimmed = phase === "transitioning";
|
||||||
|
|
||||||
const portrait = orientation === "portrait";
|
const portrait = orientation === "portrait";
|
||||||
@@ -374,7 +393,7 @@ export function PlayCanvas({
|
|||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className={`block ${portrait ? "" : "w-auto h-auto"} select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
className={`block ${portrait ? "" : "w-auto h-auto"} select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
||||||
interactive ? "cursor-pointer" : "cursor-wait"
|
imageClickable ? "cursor-pointer" : interactive ? "cursor-default" : "cursor-wait"
|
||||||
} ${dimmed ? "opacity-40" : "opacity-100"}`}
|
} ${dimmed ? "opacity-40" : "opacity-100"}`}
|
||||||
style={sizeStyle}
|
style={sizeStyle}
|
||||||
/>
|
/>
|
||||||
@@ -394,6 +413,11 @@ export function PlayCanvas({
|
|||||||
{aboveCanvasLeft}
|
{aboveCanvasLeft}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!fullViewport && belowCanvas && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 flex items-center gap-2">
|
||||||
|
{belowCanvas}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{beat && (
|
{beat && (
|
||||||
<div
|
<div
|
||||||
@@ -404,24 +428,144 @@ export function PlayCanvas({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{historyOpen && (
|
||||||
|
<DialogueHistoryModal
|
||||||
|
items={dialogueHistory}
|
||||||
|
portrait={portrait}
|
||||||
|
onClose={() => setHistoryOpen(false)}
|
||||||
|
playerName={playerName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{choices.length > 0 && (
|
{choices.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-auto px-[3%] pb-[1.5%] flex items-stretch ${
|
className={`pointer-events-auto px-[3%] pb-[1.5%] flex items-stretch ${
|
||||||
portrait
|
portrait
|
||||||
? "flex-col gap-2 max-h-[45dvh] overflow-y-auto"
|
? "vn-scrollbar flex-col gap-2 max-h-[45dvh] overflow-y-auto"
|
||||||
: "gap-[1.5%]"
|
: "gap-[1.5%]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{choices.map((choice, i) => (
|
{freeformOpen && onFreeformInput ? (
|
||||||
<ChoiceButton
|
/* ── Expanded: full-width input replaces all choices ── */
|
||||||
key={choice.id}
|
<div
|
||||||
index={i}
|
className="flex-1 flex items-center gap-2"
|
||||||
label={choice.label}
|
style={{
|
||||||
disabled={phase !== "ready"}
|
background: "rgba(20, 14, 8, 0.68)",
|
||||||
vertical={portrait}
|
border: "1.5px solid rgba(180, 140, 80, 0.65)",
|
||||||
onClick={() => onSelectChoice(choice)}
|
borderRadius: "6px",
|
||||||
/>
|
backdropFilter: "blur(8px)",
|
||||||
))}
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
|
boxShadow: "0 2px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(200,165,90,0.12)",
|
||||||
|
padding: "8px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={freeformInputRef}
|
||||||
|
value={freeformText}
|
||||||
|
onChange={(e) => setFreeformText(e.target.value.slice(0, 50))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.nativeEvent.isComposing && freeformText.trim() && phase === "ready") {
|
||||||
|
onFreeformInput(freeformText.trim());
|
||||||
|
setFreeformOpen(false);
|
||||||
|
setFreeformText("");
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setFreeformOpen(false);
|
||||||
|
setFreeformText("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="输入你想说的或想做的..."
|
||||||
|
maxLength={50}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 min-w-0 bg-transparent border-none outline-none font-serif text-[14px] placeholder:text-[rgba(200,185,155,0.50)]"
|
||||||
|
style={{ color: "rgba(245,235,210,0.95)" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!freeformText.trim() || phase !== "ready"}
|
||||||
|
onClick={() => {
|
||||||
|
if (freeformText.trim()) {
|
||||||
|
onFreeformInput(freeformText.trim());
|
||||||
|
setFreeformOpen(false);
|
||||||
|
setFreeformText("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-sm transition-colors disabled:opacity-30"
|
||||||
|
style={{ color: "rgba(195,155,75,0.9)" }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-paper-plane text-[12px]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setFreeformOpen(false); setFreeformText(""); }}
|
||||||
|
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-sm transition-colors"
|
||||||
|
style={{ color: "rgba(200,185,155,0.55)" }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-xmark text-[13px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Collapsed: normal choices + small freeform trigger ── */
|
||||||
|
<>
|
||||||
|
{choices.map((choice, i) => (
|
||||||
|
<ChoiceButton
|
||||||
|
key={choice.id}
|
||||||
|
index={i}
|
||||||
|
label={choice.label}
|
||||||
|
disabled={phase !== "ready"}
|
||||||
|
vertical={portrait}
|
||||||
|
onClick={() => onSelectChoice(choice)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{onFreeformInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={phase !== "ready"}
|
||||||
|
onClick={() => {
|
||||||
|
setFreeformOpen(true);
|
||||||
|
requestAnimationFrame(() => freeformInputRef.current?.focus());
|
||||||
|
}}
|
||||||
|
className="group shrink-0 flex items-center justify-center transition-all duration-200 disabled:opacity-50 disabled:cursor-wait"
|
||||||
|
style={{
|
||||||
|
background: "rgba(20, 14, 8, 0.45)",
|
||||||
|
border: "1.5px dashed rgba(180, 140, 80, 0.40)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
|
width: portrait ? "100%" : "42px",
|
||||||
|
padding: portrait ? "10px 16px" : "0",
|
||||||
|
}}
|
||||||
|
title="自由输入"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="opacity-0 group-hover:opacity-100 absolute inset-0 rounded-[5px] transition-opacity duration-200 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: "rgba(180,140,60,0.08)",
|
||||||
|
border: "1.5px dashed rgba(200,165,90,0.70)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{portrait ? (
|
||||||
|
<span className="relative flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
className="fa-solid fa-pen-to-square text-[11px]"
|
||||||
|
style={{ color: "rgba(195,155,75,0.60)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-serif text-[13px]"
|
||||||
|
style={{ color: "rgba(200,185,155,0.70)" }}
|
||||||
|
>
|
||||||
|
自由输入
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<i
|
||||||
|
className="fa-solid fa-pen-to-square text-[12px] relative"
|
||||||
|
style={{ color: "rgba(195,155,75,0.55)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -461,7 +605,7 @@ export function PlayCanvas({
|
|||||||
}`}
|
}`}
|
||||||
style={{ color: "rgba(205,165,90,0.92)" }}
|
style={{ color: "rgba(205,165,90,0.92)" }}
|
||||||
>
|
>
|
||||||
{beat.speaker}
|
{displaySpeaker(beat.speaker)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -487,13 +631,43 @@ export function PlayCanvas({
|
|||||||
|
|
||||||
{typingDone && beat.next.type === "continue" && (
|
{typingDone && beat.next.type === "continue" && (
|
||||||
<span
|
<span
|
||||||
className="absolute bottom-[6px] right-[10px] text-[10px] animate-slow-pulse"
|
className={`absolute bottom-[6px] ${onOpenSettings ? "right-[74px]" : "right-[42px]"} text-[10px] animate-slow-pulse`}
|
||||||
style={{ color: "rgba(195,155,75,0.7)" }}
|
style={{ color: "rgba(195,155,75,0.7)" }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onOpenSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSettings();
|
||||||
|
}}
|
||||||
|
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
|
||||||
|
aria-label="打开设置"
|
||||||
|
title="设置"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-gear text-[12px]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setHistoryOpen(true);
|
||||||
|
}}
|
||||||
|
className={`absolute bottom-[6px] ${
|
||||||
|
onOpenSettings ? "right-[40px]" : "right-[8px]"
|
||||||
|
} flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]`}
|
||||||
|
aria-label="打开剧情回溯"
|
||||||
|
title="剧情回溯"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-clock-rotate-left text-[12px]" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -561,6 +735,11 @@ export function PlayCanvas({
|
|||||||
{aboveCanvasLeft}
|
{aboveCanvasLeft}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!fullViewport && belowCanvas && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 flex items-center gap-2">
|
||||||
|
{belowCanvas}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
clearStoredTtsConfig,
|
||||||
|
readStoredTtsConfig,
|
||||||
|
writeStoredTtsConfig,
|
||||||
|
} from "@/lib/clientTtsConfig";
|
||||||
|
import {
|
||||||
|
findTtsPreset,
|
||||||
|
PAYG_PRESET_ID,
|
||||||
|
TTS_KEY_DOC_URL,
|
||||||
|
TTS_REGION_PRESETS,
|
||||||
|
} from "@/lib/ttsPresets";
|
||||||
|
|
||||||
|
const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName";
|
||||||
|
const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick";
|
||||||
|
|
||||||
|
export function readStoredPlayerName(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(PLAYER_NAME_STORAGE_KEY) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredPlayerName(name: string): void {
|
||||||
|
try {
|
||||||
|
if (name) {
|
||||||
|
localStorage.setItem(PLAYER_NAME_STORAGE_KEY, name);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(PLAYER_NAME_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredVisionClick(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(VISION_CLICK_STORAGE_KEY) !== "0";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({
|
||||||
|
initialVisionClickEnabled = true,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
footerNote,
|
||||||
|
}: {
|
||||||
|
initialVisionClickEnabled?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => void;
|
||||||
|
footerNote?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [initialTts] = useState(() => readStoredTtsConfig());
|
||||||
|
const initialKind = findTtsPreset(initialTts?.presetId)?.kind ?? "payg";
|
||||||
|
const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind);
|
||||||
|
const [regionId, setRegionId] = useState<string>(
|
||||||
|
initialKind === "token-plan"
|
||||||
|
? (initialTts?.presetId ?? TTS_REGION_PRESETS[0]!.id)
|
||||||
|
: TTS_REGION_PRESETS[0]!.id,
|
||||||
|
);
|
||||||
|
const [apiKey, setApiKey] = useState<string>(initialTts?.apiKey ?? "");
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const ttsAlreadyConfigured = initialTts != null;
|
||||||
|
|
||||||
|
const [playerName, setPlayerName] = useState(() => readStoredPlayerName());
|
||||||
|
const [visionClick, setVisionClick] = useState(initialVisionClickEnabled);
|
||||||
|
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
|
||||||
|
const expectedPrefix = keyType === "payg" ? "sk-" : "tp-";
|
||||||
|
const prefixMismatch =
|
||||||
|
apiKey.trim().length > 0 && !apiKey.trim().startsWith(expectedPrefix);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => setShown(true));
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setShown(false);
|
||||||
|
setTimeout(onClose, 280);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const name = playerName.trim();
|
||||||
|
writeStoredPlayerName(name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
const key = apiKey.trim();
|
||||||
|
let ttsConfigured = false;
|
||||||
|
if (key) {
|
||||||
|
const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId;
|
||||||
|
writeStoredTtsConfig({ presetId, apiKey: key });
|
||||||
|
ttsConfigured = true;
|
||||||
|
} else {
|
||||||
|
clearStoredTtsConfig();
|
||||||
|
ttsConfigured = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved({ ttsConfigured, playerName: name, visionClickEnabled: visionClick });
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
clearStoredTtsConfig();
|
||||||
|
writeStoredPlayerName("");
|
||||||
|
try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ }
|
||||||
|
onSaved({ ttsConfigured: false, playerName: "", visionClickEnabled: true });
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnySetting = ttsAlreadyConfigured || readStoredPlayerName().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseDown={close}
|
||||||
|
className={
|
||||||
|
"fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10 transition-all duration-300 " +
|
||||||
|
(shown
|
||||||
|
? "bg-clay-900/30 backdrop-blur-md"
|
||||||
|
: "bg-clay-900/0 backdrop-blur-0")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className={
|
||||||
|
"flex w-[560px] max-w-[94vw] max-h-[88vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
|
||||||
|
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-serif text-xl md:text-2xl text-clay-900">
|
||||||
|
设置
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
||||||
|
可选 · 这些设置仅保存在本地浏览器
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="关闭"
|
||||||
|
className="ml-auto text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-xmark" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0 overflow-y-auto">
|
||||||
|
{/* ── Player Name Section ── */}
|
||||||
|
<div className="flex flex-col gap-3 px-6 md:px-8 py-5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
|
||||||
|
<i className="fa-solid fa-user-pen text-[11px]" />
|
||||||
|
</span>
|
||||||
|
<span className="font-serif text-base text-clay-900">
|
||||||
|
玩家名字
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={playerName}
|
||||||
|
onChange={(e) => setPlayerName(e.target.value)}
|
||||||
|
type="text"
|
||||||
|
maxLength={20}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="不填则使用「你」"
|
||||||
|
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] text-clay-400">
|
||||||
|
NPC 会在对话中用这个名字称呼你。不填则默认以「你」称呼。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
|
||||||
|
|
||||||
|
{/* ── Vision Click Section ── */}
|
||||||
|
<div className="flex flex-col gap-3 px-6 md:px-8 py-5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
|
||||||
|
<i className="fa-solid fa-eye text-[11px]" />
|
||||||
|
</span>
|
||||||
|
<span className="font-serif text-base text-clay-900">
|
||||||
|
点击画面识别
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" },
|
||||||
|
{ on: false, label: "关闭", icon: "fa-solid fa-ban" },
|
||||||
|
] as const
|
||||||
|
).map((t) => {
|
||||||
|
const active = visionClick === t.on;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(t.on)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisionClick(t.on)}
|
||||||
|
className={
|
||||||
|
"flex items-center justify-center gap-2 rounded-sm border px-3 py-2.5 text-[13px] transition-all " +
|
||||||
|
(active
|
||||||
|
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||||
|
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className={t.icon + " text-[11px]"} />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-clay-400">
|
||||||
|
开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
|
||||||
|
|
||||||
|
{/* ── TTS Key Section ── */}
|
||||||
|
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
|
||||||
|
<i className="fa-solid fa-key text-[11px]" />
|
||||||
|
</span>
|
||||||
|
<span className="font-serif text-base text-clay-900">
|
||||||
|
自带配音 Key
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-clay-400">可选</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] leading-relaxed text-clay-500">
|
||||||
|
填入你自己的
|
||||||
|
<span className="text-clay-800"> 小米 MiMo API Key</span>
|
||||||
|
,配音将在浏览器本地合成,Key 只保存在本地、绝不经过服务器。MiMo
|
||||||
|
TTS 目前
|
||||||
|
<span className="text-clay-800">限时免费</span>
|
||||||
|
,申请即可使用。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] smallcaps text-clay-500">
|
||||||
|
K e y · 类 型
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "payg",
|
||||||
|
label: "按量付费 Pay-as-you-go",
|
||||||
|
sub: "sk- 开头",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "token-plan",
|
||||||
|
label: "套餐 Token Plan",
|
||||||
|
sub: "tp- 开头",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).map((t) => {
|
||||||
|
const active = keyType === t.kind;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.kind}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKeyType(t.kind)}
|
||||||
|
className={
|
||||||
|
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
|
||||||
|
(active
|
||||||
|
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||||
|
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-[13px]">{t.label}</span>
|
||||||
|
<span className="text-[10px] text-clay-400">
|
||||||
|
{t.sub}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keyType === "token-plan" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] smallcaps text-clay-500">
|
||||||
|
区 域 节 点
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||||
|
{TTS_REGION_PRESETS.map((p) => {
|
||||||
|
const active = p.id === regionId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRegionId(p.id)}
|
||||||
|
className={
|
||||||
|
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
|
||||||
|
(active
|
||||||
|
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||||
|
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-clay-400">
|
||||||
|
选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] smallcaps text-clay-500">
|
||||||
|
A P I · K e y
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={
|
||||||
|
keyType === "payg"
|
||||||
|
? "粘贴 sk- 开头的按量 Key"
|
||||||
|
: "粘贴 tp- 开头的套餐 Key"
|
||||||
|
}
|
||||||
|
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
aria-label={showKey ? "隐藏" : "显示"}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{prefixMismatch && (
|
||||||
|
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
|
||||||
|
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
|
||||||
|
此 Key 不是 {expectedPrefix} 开头,可能与所选「
|
||||||
|
{keyType === "payg"
|
||||||
|
? "按量付费 Pay-as-you-go"
|
||||||
|
: "套餐 Token Plan"}
|
||||||
|
」类型不符,请确认是否填错。
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={TTS_KEY_DOC_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fa-brands fa-github text-[11px]" />
|
||||||
|
如何免费申请 Key?查看图文教程
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footerNote && (
|
||||||
|
<p className="text-[11px] leading-relaxed text-clay-400">
|
||||||
|
{footerNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center gap-3 border-t border-clay-900/10 px-6 md:px-8 py-4">
|
||||||
|
{hasAnySetting && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearAll}
|
||||||
|
className="inline-flex items-center gap-2 rounded-sm border border-clay-900/15 px-4 py-2 font-sans text-sm text-clay-600 transition-colors hover:border-clay-900/35 hover:text-clay-900"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-rotate-left text-xs" />
|
||||||
|
全部清除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
className="ml-auto inline-flex items-center gap-2 rounded-sm bg-clay-900 px-5 py-2.5 font-sans text-sm text-cream-50 transition-colors hover:bg-ember-500"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-check text-xs" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Bring-your-own Xiaomi MiMo TTS key modal — shared by the homepage and the
|
|
||||||
// play page. Two-step picker (key family → region for Token Plan only), key
|
|
||||||
// stored CLIENT-SIDE ONLY (see lib/clientTtsConfig). `onSaved(configured)`
|
|
||||||
// fires after a save/disable so each host can react (homepage flips the
|
|
||||||
// 语音配音 toggle; the play page re-synthesizes the current scene in-browser).
|
|
||||||
// `footerNote` lets the host tailor the closing hint to its own context.
|
|
||||||
|
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
clearStoredTtsConfig,
|
|
||||||
readStoredTtsConfig,
|
|
||||||
writeStoredTtsConfig,
|
|
||||||
} from "@/lib/clientTtsConfig";
|
|
||||||
import {
|
|
||||||
findTtsPreset,
|
|
||||||
PAYG_PRESET_ID,
|
|
||||||
TTS_KEY_DOC_URL,
|
|
||||||
TTS_REGION_PRESETS,
|
|
||||||
} from "@/lib/ttsPresets";
|
|
||||||
|
|
||||||
const DEFAULT_FOOTER_NOTE: ReactNode =
|
|
||||||
"提示:需将上方「语音配音」设为「开启」配音才会生效。保存后本设备后续游玩会自动使用此 Key。";
|
|
||||||
|
|
||||||
export function TtsKeyModal({
|
|
||||||
onClose,
|
|
||||||
onSaved,
|
|
||||||
footerNote = DEFAULT_FOOTER_NOTE,
|
|
||||||
}: {
|
|
||||||
onClose: () => void;
|
|
||||||
onSaved: (configured: boolean) => void;
|
|
||||||
footerNote?: ReactNode;
|
|
||||||
}) {
|
|
||||||
// Read storage once; useState initializers ignore later renders, so local
|
|
||||||
// edits aren't clobbered and we don't re-hit localStorage every render.
|
|
||||||
const [initial] = useState(() => readStoredTtsConfig());
|
|
||||||
// Two-step picker: choose key family first, then — only for Token Plan — a
|
|
||||||
// region. Pay-as-you-go (`sk-`) keys hit one fixed endpoint, so no region.
|
|
||||||
const initialKind = findTtsPreset(initial?.presetId)?.kind ?? "token-plan";
|
|
||||||
const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind);
|
|
||||||
const [regionId, setRegionId] = useState<string>(
|
|
||||||
initialKind === "token-plan"
|
|
||||||
? (initial?.presetId ?? TTS_REGION_PRESETS[0]!.id)
|
|
||||||
: TTS_REGION_PRESETS[0]!.id,
|
|
||||||
);
|
|
||||||
const [apiKey, setApiKey] = useState<string>(initial?.apiKey ?? "");
|
|
||||||
const [showKey, setShowKey] = useState(false);
|
|
||||||
const [shown, setShown] = useState(false);
|
|
||||||
const alreadyConfigured = initial != null;
|
|
||||||
// Soft guard: tp- keys belong to Token Plan, sk- to pay-as-you-go. A
|
|
||||||
// mismatched pairing hits the wrong endpoint → guaranteed auth failure →
|
|
||||||
// silent playback (the very symptom BYO exists to kill). Warn, but never
|
|
||||||
// block: prefix conventions could change and a hard gate would lock out an
|
|
||||||
// otherwise-valid key.
|
|
||||||
const expectedPrefix = keyType === "payg" ? "sk-" : "tp-";
|
|
||||||
const prefixMismatch =
|
|
||||||
apiKey.trim().length > 0 && !apiKey.trim().startsWith(expectedPrefix);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = requestAnimationFrame(() => setShown(true));
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
setShown(false);
|
|
||||||
setTimeout(onClose, 280);
|
|
||||||
};
|
|
||||||
const save = () => {
|
|
||||||
const key = apiKey.trim();
|
|
||||||
if (!key) return;
|
|
||||||
const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId;
|
|
||||||
writeStoredTtsConfig({ presetId, apiKey: key });
|
|
||||||
onSaved(true);
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
const disable = () => {
|
|
||||||
clearStoredTtsConfig();
|
|
||||||
onSaved(false);
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onMouseDown={close}
|
|
||||||
className={
|
|
||||||
"fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10 transition-all duration-300 " +
|
|
||||||
(shown
|
|
||||||
? "bg-clay-900/30 backdrop-blur-md"
|
|
||||||
: "bg-clay-900/0 backdrop-blur-0")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
className={
|
|
||||||
"flex w-[560px] max-w-[94vw] max-h-[88vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
|
|
||||||
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-serif text-xl md:text-2xl text-clay-900">
|
|
||||||
自带配音 Key
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
|
||||||
可选 · 用你自己的小米 MiMo 免费额度,配音更稳定、延迟更低
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={close}
|
|
||||||
aria-label="关闭"
|
|
||||||
className="ml-auto text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-xmark" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 overflow-y-auto px-6 md:px-8 py-6">
|
|
||||||
<p className="text-[13px] leading-relaxed text-clay-600">
|
|
||||||
经常没有声音?公共语音模型有调用频率限额(RPM / TPM),同时游玩的人多时很容易撞到限额而静音。填入你自己的小米 MiMo API Key 后,配音将
|
|
||||||
<span className="text-clay-900">直接在你的浏览器里合成</span>
|
|
||||||
、使用你自己的免费额度 ——{" "}
|
|
||||||
<span className="text-clay-900">Key 只保存在本地浏览器、绝不经过我们的服务器</span>
|
|
||||||
。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-[10px] smallcaps text-clay-500">K e y · 类 型</span>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ kind: "token-plan", label: "套餐 Token Plan", sub: "tp- 开头" },
|
|
||||||
{ kind: "payg", label: "按量付费 Pay-as-you-go", sub: "sk- 开头" },
|
|
||||||
] as const
|
|
||||||
).map((t) => {
|
|
||||||
const active = keyType === t.kind;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={t.kind}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setKeyType(t.kind)}
|
|
||||||
className={
|
|
||||||
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
|
|
||||||
(active
|
|
||||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
|
||||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="text-[13px]">{t.label}</span>
|
|
||||||
<span className="text-[10px] text-clay-400">{t.sub}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{keyType === "token-plan" ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-[10px] smallcaps text-clay-500">区 域 节 点</span>
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
|
||||||
{TTS_REGION_PRESETS.map((p) => {
|
|
||||||
const active = p.id === regionId;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRegionId(p.id)}
|
|
||||||
className={
|
|
||||||
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
|
|
||||||
(active
|
|
||||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
|
||||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] text-clay-400">
|
|
||||||
选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-start gap-2 rounded-sm border border-clay-900/10 bg-cream-100/60 px-3.5 py-2.5">
|
|
||||||
<i className="fa-solid fa-circle-info mt-0.5 text-[11px] text-clay-400" />
|
|
||||||
<span className="text-[11px] leading-relaxed text-clay-500">
|
|
||||||
按量付费使用统一端点{" "}
|
|
||||||
<span className="text-clay-700">api.xiaomimimo.com</span>
|
|
||||||
,无需选择区域。
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-[10px] smallcaps text-clay-500">
|
|
||||||
A P I · K e y
|
|
||||||
</span>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
type={showKey ? "text" : "password"}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder={
|
|
||||||
keyType === "payg"
|
|
||||||
? "粘贴 sk- 开头的按量 Key"
|
|
||||||
: "粘贴 tp- 开头的套餐 Key"
|
|
||||||
}
|
|
||||||
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowKey((v) => !v)}
|
|
||||||
aria-label={showKey ? "隐藏" : "显示"}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{prefixMismatch && (
|
|
||||||
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
|
|
||||||
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
|
|
||||||
此 Key 不是 {expectedPrefix} 开头,可能与所选「
|
|
||||||
{keyType === "payg" ? "按量付费 Pay-as-you-go" : "套餐 Token Plan"}
|
|
||||||
」类型不符,请确认是否填错。
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={TTS_KEY_DOC_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
|
|
||||||
>
|
|
||||||
<i className="fa-brands fa-github text-[11px]" />
|
|
||||||
如何免费申请 Key?查看图文教程
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] leading-relaxed text-clay-400">{footerNote}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 border-t border-clay-900/10 px-6 md:px-8 py-4">
|
|
||||||
{alreadyConfigured && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={disable}
|
|
||||||
className="inline-flex items-center gap-2 rounded-sm border border-clay-900/15 px-4 py-2 font-sans text-sm text-clay-600 transition-colors hover:border-clay-900/35 hover:text-clay-900"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-rotate-left text-xs" />
|
|
||||||
停用并清除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={save}
|
|
||||||
disabled={!apiKey.trim()}
|
|
||||||
className="ml-auto inline-flex items-center gap-2 rounded-sm bg-clay-900 px-5 py-2.5 font-sans text-sm text-cream-50 transition-colors hover:bg-ember-500 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-check text-xs" />
|
|
||||||
保存并启用
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
infiplot:
|
||||||
|
image: ghcr.io/zonghaoyuan/infiplot:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env.local
|
||||||
|
restart: unless-stopped
|
||||||
+5
-142
@@ -1,69 +1,15 @@
|
|||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import type { LanguageModelUsage, ModelMessage } from "ai";
|
import type { LanguageModelUsage, ModelMessage } from "ai";
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
import type { ProviderConfig } from "@infiplot/types";
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
import { createLanguageModel, resolveProtocol } from "./model";
|
||||||
import type { ProviderConfig, ProviderProtocol } from "@infiplot/types";
|
|
||||||
import { fetchWithRetry } from "./fetchWithRetry";
|
|
||||||
import { normalizeBaseUrl } from "./normalizeUrl";
|
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Different providers expose prompt-cache stats under different keys. We probe
|
|
||||||
// for the three forms we've seen in the wild and fall back to total tokens
|
|
||||||
// when no cache field exists.
|
|
||||||
//
|
|
||||||
// DeepSeek (v3+) usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
|
||||||
// OpenAI / o-series usage.prompt_tokens_details.cached_tokens
|
|
||||||
// Anthropic / others usage.cache_read_input_tokens / cache_creation_input_tokens
|
|
||||||
// No-cache (MiMo,
|
|
||||||
// local Ollama, …) only prompt_tokens / completion_tokens — print those
|
|
||||||
// so we still get a rough cost baseline.
|
|
||||||
type Usage = {
|
|
||||||
prompt_tokens?: number;
|
|
||||||
completion_tokens?: number;
|
|
||||||
prompt_cache_hit_tokens?: number;
|
|
||||||
prompt_cache_miss_tokens?: number;
|
|
||||||
prompt_tokens_details?: { cached_tokens?: number };
|
|
||||||
cache_read_input_tokens?: number;
|
|
||||||
cache_creation_input_tokens?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function summarizeUsage(tag: string, usage: Usage | undefined): string {
|
|
||||||
if (!usage) return `[cache] ${tag} no-usage`;
|
|
||||||
const prompt = usage.prompt_tokens ?? 0;
|
|
||||||
const completion = usage.completion_tokens ?? 0;
|
|
||||||
// DeepSeek-style
|
|
||||||
if (typeof usage.prompt_cache_hit_tokens === "number") {
|
|
||||||
const hit = usage.prompt_cache_hit_tokens;
|
|
||||||
const miss = usage.prompt_cache_miss_tokens ?? Math.max(0, prompt - hit);
|
|
||||||
const denom = hit + miss;
|
|
||||||
const rate = denom > 0 ? ((hit / denom) * 100).toFixed(1) : "n/a";
|
|
||||||
return `[cache] ${tag} hit=${hit} miss=${miss} rate=${rate}% completion=${completion}`;
|
|
||||||
}
|
|
||||||
// OpenAI-style
|
|
||||||
const oaiCached = usage.prompt_tokens_details?.cached_tokens;
|
|
||||||
if (typeof oaiCached === "number") {
|
|
||||||
const miss = Math.max(0, prompt - oaiCached);
|
|
||||||
const rate = prompt > 0 ? ((oaiCached / prompt) * 100).toFixed(1) : "n/a";
|
|
||||||
return `[cache] ${tag} hit=${oaiCached} miss=${miss} rate=${rate}% completion=${completion}`;
|
|
||||||
}
|
|
||||||
// Anthropic-style
|
|
||||||
if (typeof usage.cache_read_input_tokens === "number") {
|
|
||||||
const hit = usage.cache_read_input_tokens;
|
|
||||||
const create = usage.cache_creation_input_tokens ?? 0;
|
|
||||||
const denom = hit + create + prompt;
|
|
||||||
const rate = denom > 0 ? ((hit / denom) * 100).toFixed(1) : "n/a";
|
|
||||||
return `[cache] ${tag} hit=${hit} create=${create} miss=${prompt} rate=${rate}% completion=${completion}`;
|
|
||||||
}
|
|
||||||
// No cache field at all
|
|
||||||
return `[cache] ${tag} prompt=${prompt} completion=${completion} (provider didn't report cache stats)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI SDK 6 unifies cache stats across providers into usage.inputTokenDetails,
|
// AI SDK 6 unifies cache stats across providers into usage.inputTokenDetails,
|
||||||
// so a single shape covers Anthropic + Gemini (no per-provider probing).
|
// so a single shape covers Anthropic, Gemini, and OpenAI-compatible providers.
|
||||||
function summarizeSdkUsage(
|
function summarizeSdkUsage(
|
||||||
tag: string,
|
tag: string,
|
||||||
usage: LanguageModelUsage | undefined,
|
usage: LanguageModelUsage | undefined,
|
||||||
@@ -82,43 +28,16 @@ function summarizeSdkUsage(
|
|||||||
return `[cache] ${tag} input=${input} completion=${output} (provider didn't report cache stats)`;
|
return `[cache] ${tag} input=${input} completion=${output} (provider didn't report cache stats)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// text/vision default to the OpenAI-compatible wire protocol when unset.
|
|
||||||
function resolveTextProtocol(config: ProviderConfig): ProviderProtocol {
|
|
||||||
return config.provider ?? "openai_compatible";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function chat(
|
export async function chat(
|
||||||
config: ProviderConfig,
|
config: ProviderConfig,
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
opts?: {
|
opts?: {
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
responseFormat?: "json_object" | "text";
|
|
||||||
tag?: string;
|
tag?: string;
|
||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const protocol = resolveTextProtocol(config);
|
const protocol = resolveProtocol(config);
|
||||||
if (protocol === "anthropic" || protocol === "google") {
|
const model = createLanguageModel(config, protocol);
|
||||||
return chatViaAiSdk(config, messages, opts, protocol);
|
|
||||||
}
|
|
||||||
return chatOpenAiCompatible(config, messages, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Native Anthropic / Gemini via the Vercel AI SDK. response_format is not sent
|
|
||||||
// (Anthropic has no JSON mode); the engine relies on parseJsonLoose downstream,
|
|
||||||
// matching how it already tolerates loose JSON from every provider.
|
|
||||||
async function chatViaAiSdk(
|
|
||||||
config: ProviderConfig,
|
|
||||||
messages: ChatMessage[],
|
|
||||||
opts: { temperature?: number; tag?: string } | undefined,
|
|
||||||
protocol: "anthropic" | "google",
|
|
||||||
): Promise<string> {
|
|
||||||
const baseURL = normalizeBaseUrl(config.baseUrl, protocol);
|
|
||||||
const model =
|
|
||||||
protocol === "anthropic"
|
|
||||||
? createAnthropic({ apiKey: config.apiKey, baseURL })(config.model)
|
|
||||||
: createGoogleGenerativeAI({ apiKey: config.apiKey, baseURL })(
|
|
||||||
config.model,
|
|
||||||
);
|
|
||||||
|
|
||||||
const system = messages.find((m) => m.role === "system")?.content;
|
const system = messages.find((m) => m.role === "system")?.content;
|
||||||
const convo: ModelMessage[] = messages
|
const convo: ModelMessage[] = messages
|
||||||
@@ -142,59 +61,3 @@ async function chatViaAiSdk(
|
|||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chatOpenAiCompatible(
|
|
||||||
config: ProviderConfig,
|
|
||||||
messages: ChatMessage[],
|
|
||||||
opts?: {
|
|
||||||
temperature?: number;
|
|
||||||
responseFormat?: "json_object" | "text";
|
|
||||||
tag?: string;
|
|
||||||
},
|
|
||||||
): Promise<string> {
|
|
||||||
const url = `${normalizeBaseUrl(config.baseUrl, "openai_compatible")}/chat/completions`;
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
model: config.model,
|
|
||||||
messages,
|
|
||||||
temperature: opts?.temperature ?? 0.9,
|
|
||||||
};
|
|
||||||
if (opts?.responseFormat === "json_object") {
|
|
||||||
body.response_format = { type: "json_object" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetchWithRetry(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Chat API error ${res.status}: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let json: {
|
|
||||||
choices: { message: { content: string } }[];
|
|
||||||
usage?: Usage;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
json = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Chat API returned invalid JSON: ${text.slice(0, 500)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against empty choices array or missing message/content fields
|
|
||||||
const content = json.choices?.[0]?.message?.content;
|
|
||||||
if (typeof content !== "string") {
|
|
||||||
throw new Error(
|
|
||||||
`Chat API returned no content. Response: ${text.slice(0, 500)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(summarizeUsage(opts?.tag ?? "chat", json.usage));
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||||
|
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||||
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
|
import type { ProviderConfig, ProviderProtocol } from "@infiplot/types";
|
||||||
|
import { normalizeBaseUrl } from "./normalizeUrl";
|
||||||
|
|
||||||
|
export function resolveProtocol(config: ProviderConfig): ProviderProtocol {
|
||||||
|
return config.provider ?? "openai_compatible";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLanguageModel(config: ProviderConfig, protocol: ProviderProtocol) {
|
||||||
|
const baseURL = normalizeBaseUrl(config.baseUrl, protocol);
|
||||||
|
switch (protocol) {
|
||||||
|
case "anthropic":
|
||||||
|
return createAnthropic({ apiKey: config.apiKey, baseURL })(config.model);
|
||||||
|
case "google":
|
||||||
|
return createGoogleGenerativeAI({ apiKey: config.apiKey, baseURL })(config.model);
|
||||||
|
case "openai_compatible":
|
||||||
|
case "openai":
|
||||||
|
default:
|
||||||
|
return createOpenAI({ apiKey: config.apiKey, baseURL }).chat(config.model);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-109
@@ -1,10 +1,7 @@
|
|||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import type { ModelMessage } from "ai";
|
import type { ModelMessage } from "ai";
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
import type { ProviderConfig } from "@infiplot/types";
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
import { createLanguageModel, resolveProtocol } from "./model";
|
||||||
import type { ProviderConfig, ProviderProtocol } from "@infiplot/types";
|
|
||||||
import { fetchWithRetry } from "./fetchWithRetry";
|
|
||||||
import { normalizeBaseUrl } from "./normalizeUrl";
|
|
||||||
|
|
||||||
const VISION_TIMEOUT_MS = 60_000;
|
const VISION_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
@@ -13,55 +10,20 @@ export async function interpretClick(
|
|||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Wrap the raw base64 in a PNG data URL — the Canvas annotator on the
|
|
||||||
// client encodes as PNG. analyzeImageDataUrl handles the actual request.
|
|
||||||
return analyzeImageDataUrl(
|
return analyzeImageDataUrl(
|
||||||
config,
|
config,
|
||||||
`data:image/png;base64,${imageBase64}`,
|
`data:image/png;base64,${imageBase64}`,
|
||||||
prompt,
|
prompt,
|
||||||
{ responseFormat: "json_object" },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// text/vision default to the OpenAI-compatible wire protocol when unset.
|
|
||||||
function resolveVisionProtocol(config: ProviderConfig): ProviderProtocol {
|
|
||||||
return config.provider ?? "openai_compatible";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General single-image vision call. Accepts a complete data URL (preserves
|
|
||||||
* the source mime type, e.g. webp/jpeg) and lets the caller opt out of
|
|
||||||
* `response_format: json_object` for free-form text responses.
|
|
||||||
*/
|
|
||||||
export async function analyzeImageDataUrl(
|
export async function analyzeImageDataUrl(
|
||||||
config: ProviderConfig,
|
config: ProviderConfig,
|
||||||
imageDataUrl: string,
|
imageDataUrl: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
opts: { responseFormat?: "json_object" | "text" } = {},
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const protocol = resolveVisionProtocol(config);
|
const protocol = resolveProtocol(config);
|
||||||
if (protocol === "anthropic" || protocol === "google") {
|
const model = createLanguageModel(config, protocol);
|
||||||
return analyzeViaAiSdk(config, imageDataUrl, prompt, protocol);
|
|
||||||
}
|
|
||||||
return analyzeOpenAiCompatible(config, imageDataUrl, prompt, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Native Anthropic / Gemini multimodal via the AI SDK. The image part takes
|
|
||||||
// the full data URL directly; the SDK decodes it. response_format is not sent
|
|
||||||
// (no JSON mode on Anthropic) — the engine's parseJsonLoose handles output.
|
|
||||||
async function analyzeViaAiSdk(
|
|
||||||
config: ProviderConfig,
|
|
||||||
imageDataUrl: string,
|
|
||||||
prompt: string,
|
|
||||||
protocol: "anthropic" | "google",
|
|
||||||
): Promise<string> {
|
|
||||||
const baseURL = normalizeBaseUrl(config.baseUrl, protocol);
|
|
||||||
const model =
|
|
||||||
protocol === "anthropic"
|
|
||||||
? createAnthropic({ apiKey: config.apiKey, baseURL })(config.model)
|
|
||||||
: createGoogleGenerativeAI({ apiKey: config.apiKey, baseURL })(
|
|
||||||
config.model,
|
|
||||||
);
|
|
||||||
|
|
||||||
const messages: ModelMessage[] = [
|
const messages: ModelMessage[] = [
|
||||||
{
|
{
|
||||||
@@ -80,6 +42,7 @@ async function analyzeViaAiSdk(
|
|||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
|
maxRetries: 0,
|
||||||
abortSignal: timeoutCtrl.signal,
|
abortSignal: timeoutCtrl.signal,
|
||||||
});
|
});
|
||||||
if (typeof text !== "string" || text.length === 0) {
|
if (typeof text !== "string" || text.length === 0) {
|
||||||
@@ -90,70 +53,3 @@ async function analyzeViaAiSdk(
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeOpenAiCompatible(
|
|
||||||
config: ProviderConfig,
|
|
||||||
imageDataUrl: string,
|
|
||||||
prompt: string,
|
|
||||||
opts: { responseFormat?: "json_object" | "text" } = {},
|
|
||||||
): Promise<string> {
|
|
||||||
const url = `${normalizeBaseUrl(config.baseUrl, "openai_compatible")}/chat/completions`;
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
model: config.model,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: prompt },
|
|
||||||
{ type: "image_url", image_url: { url: imageDataUrl } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
temperature: 0.2,
|
|
||||||
};
|
|
||||||
if (opts.responseFormat === "json_object") {
|
|
||||||
body.response_format = { type: "json_object" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutCtrl = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => timeoutCtrl.abort(), VISION_TIMEOUT_MS);
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetchWithRetry(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: timeoutCtrl.signal,
|
|
||||||
retries: 0,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Vision API error ${res.status}: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let json: { choices: { message: { content: string } }[] };
|
|
||||||
try {
|
|
||||||
json = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Vision API returned invalid JSON: ${text.slice(0, 500)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against empty choices array or missing message/content fields
|
|
||||||
const content = json.choices?.[0]?.message?.content;
|
|
||||||
if (typeof content !== "string") {
|
|
||||||
throw new Error(
|
|
||||||
`Vision API returned no content. Response: ${text.slice(0, 500)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ type AnalyticsEventData = {
|
|||||||
kind: "advance-beat" | "change-scene";
|
kind: "advance-beat" | "change-scene";
|
||||||
};
|
};
|
||||||
vision_click: { result: "insert-beat" | "change-scene" };
|
vision_click: { result: "insert-beat" | "change-scene" };
|
||||||
|
freeform_input: { scene_index: number; text_length: number };
|
||||||
tts_toggle: { muted: boolean };
|
tts_toggle: { muted: boolean };
|
||||||
fullscreen_toggle: { on: boolean };
|
fullscreen_toggle: { on: boolean };
|
||||||
play_heartbeat: never;
|
play_heartbeat: never;
|
||||||
|
gallery_export: { scene_count: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AnalyticsEvent = keyof AnalyticsEventData;
|
export type AnalyticsEvent = keyof AnalyticsEventData;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export async function runArchitect(
|
|||||||
{ role: "system", content: ARCHITECT_SYSTEM },
|
{ role: "system", content: ARCHITECT_SYSTEM },
|
||||||
{ role: "user", content: buildArchitectUserMessage(session) },
|
{ role: "user", content: buildArchitectUserMessage(session) },
|
||||||
],
|
],
|
||||||
{ temperature: 0.85, responseFormat: "json_object", tag: "architect" },
|
{ temperature: 0.85, tag: "architect" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = parseJsonLoose<RawStoryState>(raw);
|
const parsed = parseJsonLoose<RawStoryState>(raw);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async function runDesignLLM(
|
|||||||
content: buildCharacterDesignerUserMessage(charName, session),
|
content: buildCharacterDesignerUserMessage(charName, session),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ temperature: 0.7, responseFormat: "json_object", tag: "character-designer" },
|
{ temperature: 0.7, tag: "character-designer" },
|
||||||
);
|
);
|
||||||
return parseJsonLoose<CharacterDesignOutput>(raw);
|
return parseJsonLoose<CharacterDesignOutput>(raw);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function runCinematographer(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ temperature: 0.6, responseFormat: "json_object", tag: "cinematographer" },
|
{ temperature: 0.6, tag: "cinematographer" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = parseJsonLoose<RawCinematographerOutput>(raw);
|
const parsed = parseJsonLoose<RawCinematographerOutput>(raw);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { chat } from "@infiplot/ai-client";
|
||||||
|
import type { ProviderConfig } from "@infiplot/types";
|
||||||
|
import { STYLE_MAP } from "@/lib/options";
|
||||||
|
|
||||||
|
const STYLE_NAMES = Object.keys(STYLE_MAP);
|
||||||
|
|
||||||
|
const SYSTEM = `You are an art director for a visual novel. Given the story premise, pick the single best-matching art style from the list below. Consider the genre, mood, setting, and target audience.
|
||||||
|
|
||||||
|
Available styles:
|
||||||
|
${STYLE_NAMES.map((s) => `- ${s}`).join("\n")}
|
||||||
|
|
||||||
|
Reply with ONLY the style name, nothing else. If uncertain, default to 吉卜力.`;
|
||||||
|
|
||||||
|
export async function selectStyle(
|
||||||
|
textConfig: ProviderConfig,
|
||||||
|
worldSetting: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await chat(
|
||||||
|
textConfig,
|
||||||
|
[
|
||||||
|
{ role: "system", content: SYSTEM },
|
||||||
|
{ role: "user", content: worldSetting },
|
||||||
|
],
|
||||||
|
{ temperature: 0, tag: "styleSelector" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const picked = result.trim();
|
||||||
|
if (STYLE_MAP[picked]) {
|
||||||
|
return STYLE_MAP[picked];
|
||||||
|
}
|
||||||
|
const fuzzy = picked
|
||||||
|
? STYLE_NAMES.find((s) => picked.includes(s) || s.includes(picked))
|
||||||
|
: undefined;
|
||||||
|
if (fuzzy) {
|
||||||
|
return STYLE_MAP[fuzzy]!;
|
||||||
|
}
|
||||||
|
console.warn(`[styleSelector] unrecognized style "${picked}", falling back to 吉卜力`);
|
||||||
|
return STYLE_MAP["吉卜力"]!;
|
||||||
|
}
|
||||||
@@ -423,7 +423,7 @@ export async function runWriterPlan(
|
|||||||
{ role: "system", content: WRITER_PLAN_SYSTEM },
|
{ role: "system", content: WRITER_PLAN_SYSTEM },
|
||||||
{ role: "user", content: buildWriterPlanUserMessage(session) },
|
{ role: "user", content: buildWriterPlanUserMessage(session) },
|
||||||
],
|
],
|
||||||
{ temperature: 0.9, responseFormat: "json_object", tag: "writer-plan" },
|
{ temperature: 0.9, tag: "writer-plan" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = parseJsonLoose<RawPlan>(raw);
|
const parsed = parseJsonLoose<RawPlan>(raw);
|
||||||
@@ -473,7 +473,7 @@ export async function runWriterBeats(
|
|||||||
{ role: "system", content: WRITER_BEATS_SYSTEM },
|
{ role: "system", content: WRITER_BEATS_SYSTEM },
|
||||||
{ role: "user", content: buildWriterBeatsUserMessage(session, plan) },
|
{ role: "user", content: buildWriterBeatsUserMessage(session, plan) },
|
||||||
],
|
],
|
||||||
{ temperature: 0.9, responseFormat: "json_object", tag: "writer-beats" },
|
{ temperature: 0.9, tag: "writer-beats" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = parseJsonLoose<RawBeats>(raw);
|
const parsed = parseJsonLoose<RawBeats>(raw);
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ export async function directInsertBeat(
|
|||||||
content: buildInsertBeatUserMessage(session, freeformAction),
|
content: buildInsertBeatUserMessage(session, freeformAction),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ temperature: 0.9, responseFormat: "json_object", tag: "insert-beat" },
|
{ temperature: 0.9, tag: "insert-beat" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = parseJsonLoose<InsertBeatPartial>(raw);
|
const parsed = parseJsonLoose<InsertBeatPartial>(raw);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
startSession,
|
startSession,
|
||||||
requestScene,
|
requestScene,
|
||||||
visionDecide,
|
visionDecide,
|
||||||
|
classifyFreeform,
|
||||||
requestInsertBeat,
|
requestInsertBeat,
|
||||||
requestBeatAudio,
|
requestBeatAudio,
|
||||||
} from "./orchestrator";
|
} from "./orchestrator";
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import type {
|
|||||||
BeatAudioRequest,
|
BeatAudioRequest,
|
||||||
BeatAudioResponse,
|
BeatAudioResponse,
|
||||||
EngineConfig,
|
EngineConfig,
|
||||||
|
FreeformClassify,
|
||||||
|
FreeformClassifyRequest,
|
||||||
|
FreeformClassifyResponse,
|
||||||
InsertBeatRequest,
|
InsertBeatRequest,
|
||||||
InsertBeatResponse,
|
InsertBeatResponse,
|
||||||
Session,
|
Session,
|
||||||
@@ -13,8 +16,16 @@ import type {
|
|||||||
VisionResponse,
|
VisionResponse,
|
||||||
} from "@infiplot/types";
|
} from "@infiplot/types";
|
||||||
import { coerceOrientation } from "@infiplot/types";
|
import { coerceOrientation } from "@infiplot/types";
|
||||||
|
import { chat } from "@infiplot/ai-client";
|
||||||
import { runArchitect } from "./agents/architect";
|
import { runArchitect } from "./agents/architect";
|
||||||
|
import { selectStyle } from "./agents/styleSelector";
|
||||||
import { directInsertBeat, directScene } from "./director";
|
import { directInsertBeat, directScene } from "./director";
|
||||||
|
import { STYLE_MAP } from "@/lib/options";
|
||||||
|
import { parseJsonLoose } from "./jsonParser";
|
||||||
|
import {
|
||||||
|
FREEFORM_CLASSIFY_SYSTEM,
|
||||||
|
buildFreeformClassifyUserMessage,
|
||||||
|
} from "./prompts";
|
||||||
import { synthesizeBeat } from "./voice";
|
import { synthesizeBeat } from "./voice";
|
||||||
import { interpret } from "./vision";
|
import { interpret } from "./vision";
|
||||||
|
|
||||||
@@ -50,18 +61,34 @@ export async function startSession(
|
|||||||
characters: [],
|
characters: [],
|
||||||
styleReferenceImage: req.styleReferenceImage?.trim() || undefined,
|
styleReferenceImage: req.styleReferenceImage?.trim() || undefined,
|
||||||
orientation: coerceOrientation(req.orientation),
|
orientation: coerceOrientation(req.orientation),
|
||||||
|
playerName: req.playerName?.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stage 0 — Architect: expand the terse world/style prompt into a story
|
// Stage 0 — Architect (+ optional auto style selection, in parallel).
|
||||||
// bible BEFORE the first scene. Serial by necessity (the opening Writer
|
// Both only depend on worldSetting, so they run concurrently.
|
||||||
// reads session.storyState), but it gives the whole story a spine from beat
|
|
||||||
// one — the latency is offset by the director's portrait/voice overlap win.
|
|
||||||
console.log(
|
console.log(
|
||||||
`[start] worldSetting (${session.worldSetting.length} chars):\n${session.worldSetting}`,
|
`[start] worldSetting (${session.worldSetting.length} chars):\n${session.worldSetting}`,
|
||||||
);
|
);
|
||||||
|
const isAutoStyle = session.styleGuide === "auto";
|
||||||
|
if (isAutoStyle) {
|
||||||
|
session.styleGuide = "由 AI 根据剧情自动匹配最佳画风";
|
||||||
|
}
|
||||||
const tArchitect = Date.now();
|
const tArchitect = Date.now();
|
||||||
session.storyState = await runArchitect(config.text, session);
|
const [architectResult, autoStyleGuide] = await Promise.all([
|
||||||
tlog("[start] Architect", tArchitect);
|
runArchitect(config.text, session),
|
||||||
|
isAutoStyle
|
||||||
|
? selectStyle(config.text, session.worldSetting).catch((err) => {
|
||||||
|
console.warn(`[styleSelector] failed, falling back to 吉卜力:`, err);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
session.storyState = architectResult;
|
||||||
|
if (isAutoStyle) {
|
||||||
|
session.styleGuide = autoStyleGuide ?? STYLE_MAP["吉卜力"]!;
|
||||||
|
console.log(`[start] auto-selected style: ${session.styleGuide.slice(0, 60)}…`);
|
||||||
|
}
|
||||||
|
tlog("[start] Architect" + (isAutoStyle ? " + StyleSelector" : ""), tArchitect);
|
||||||
console.log(
|
console.log(
|
||||||
`[start] storyBible: logline="${session.storyState.logline}" | genreTags="${session.storyState.genreTags}" | synopsis="${session.storyState.synopsis}"`,
|
`[start] storyBible: logline="${session.storyState.logline}" | genreTags="${session.storyState.genreTags}" | synopsis="${session.storyState.synopsis}"`,
|
||||||
);
|
);
|
||||||
@@ -121,6 +148,41 @@ export async function visionDecide(
|
|||||||
return interpret(config.vision, req.annotatedImageBase64, current);
|
return interpret(config.vision, req.annotatedImageBase64, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// classifyFreeform — classifies a freeform text input at a choice node
|
||||||
|
// into match-choice / insert-beat / change-scene. Single lightweight
|
||||||
|
// LLM call; no image, no scene generation.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function classifyFreeform(
|
||||||
|
config: EngineConfig,
|
||||||
|
req: FreeformClassifyRequest,
|
||||||
|
): Promise<FreeformClassifyResponse> {
|
||||||
|
const current = req.session.history.at(-1)?.scene ?? null;
|
||||||
|
const userMsg = buildFreeformClassifyUserMessage(
|
||||||
|
req.freeformText,
|
||||||
|
current?.scenePrompt,
|
||||||
|
);
|
||||||
|
|
||||||
|
const raw = await chat(config.text, [
|
||||||
|
{ role: "system", content: FREEFORM_CLASSIFY_SYSTEM },
|
||||||
|
{ role: "user", content: userMsg },
|
||||||
|
], { temperature: 0, tag: "freeform-classify" });
|
||||||
|
|
||||||
|
const parsed = parseJsonLoose<{
|
||||||
|
classify?: string;
|
||||||
|
freeformAction?: string;
|
||||||
|
}>(raw);
|
||||||
|
|
||||||
|
const classify: FreeformClassify =
|
||||||
|
parsed.classify === "change-scene" ? "change-scene" : "insert-beat";
|
||||||
|
|
||||||
|
return {
|
||||||
|
classify,
|
||||||
|
freeformAction: parsed.freeformAction?.trim() || req.freeformText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// requestInsertBeat — single-agent transient beat (no image, no new
|
// requestInsertBeat — single-agent transient beat (no image, no new
|
||||||
// characters). Stays single-LLM by design — the INSERT_BEAT prompt
|
// characters). Stays single-LLM by design — the INSERT_BEAT prompt
|
||||||
|
|||||||
+82
-9
@@ -132,6 +132,11 @@ export function buildArchitectUserMessage(session: Session): string {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push(`世界观:${session.worldSetting}`);
|
parts.push(`世界观:${session.worldSetting}`);
|
||||||
parts.push(`画风:${session.styleGuide}`);
|
parts.push(`画风:${session.styleGuide}`);
|
||||||
|
if (session.playerName) {
|
||||||
|
parts.push(
|
||||||
|
`\n玩家名字:${session.playerName}\n(NPC 在对话中应自然地称呼玩家为「${session.playerName}」。「你」仍指代玩家视角,但 NPC 的台词里请使用这个名字而非泛称。不要为玩家设计立绘或音色——玩家是 POV 视角,永不出现在画面中。)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
"\n请据此产出这部交互剧的故事档案(story bible),严格以 JSON 格式返回。",
|
"\n请据此产出这部交互剧的故事档案(story bible),严格以 JSON 格式返回。",
|
||||||
);
|
);
|
||||||
@@ -421,6 +426,11 @@ function buildWriterContextParts(session: Session): string[] {
|
|||||||
// ── 1. session scalars ────────────────────────────────────────────────
|
// ── 1. session scalars ────────────────────────────────────────────────
|
||||||
parts.push(`世界观:${session.worldSetting}`);
|
parts.push(`世界观:${session.worldSetting}`);
|
||||||
parts.push(`画风:${session.styleGuide}`);
|
parts.push(`画风:${session.styleGuide}`);
|
||||||
|
if (session.playerName) {
|
||||||
|
parts.push(
|
||||||
|
`玩家名字:${session.playerName}(NPC 对话时用此名字称呼玩家;speaker 字段仍固定为 "你" 不变)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
|
|
||||||
// ── 2. story bible — spine only (stable) ──────────────────────────────
|
// ── 2. story bible — spine only (stable) ──────────────────────────────
|
||||||
@@ -874,26 +884,38 @@ STRICT RULES:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Insert-Beat — given a freeform vision action that is judged to stay
|
// Insert-Beat — given a freeform action (background click or typed
|
||||||
// *within* the current scene, generate one transient beat.
|
// input) that stays *within* the current scene, generate one beat
|
||||||
|
// with meaningful character interaction.
|
||||||
// Single-agent path; no character design / no rendering involved.
|
// Single-agent path; no character design / no rendering involved.
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个**不会换场景的自由动作**(比如看一眼桌上的相框、想了想刚才那句话)。请基于此动作,写出一个**单独的、过渡性的 beat**:可以是旁白、角色台词、或两者结合。
|
export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个自由动作(可能是点击画面中的某个物件/角色,也可能是主动输入了一句话/动作)。请基于此动作,写出**一个有实质内容的 beat**。
|
||||||
|
|
||||||
|
核心原则——**玩家的动作必须得到回应**:
|
||||||
|
- 如果当前场景有 NPC 在场,NPC **必须对玩家的动作做出反应**(说话、表情变化、动作回应)。用 narration 描述玩家的动作,用 speaker + line 写 NPC 的回应。
|
||||||
|
- 如果场景中没有 NPC(纯环境),可以用 narration 描述玩家的观察/发现,给玩家一个新细节或情绪波动。
|
||||||
|
- 不要写"你想做什么但没做"这种无意义的犹豫——玩家已经做了,世界要有反馈。
|
||||||
|
|
||||||
文本风格约束:
|
文本风格约束:
|
||||||
- narration / line 用中文,**纯净可显示文本**,不要写 (叹气) 这类配音标注
|
- narration / line 用中文,**纯净可显示文本**,不要写 (叹气)(语速快) 这类配音标注
|
||||||
- narration 与 line 加起来 ≤80 字
|
- narration 与 line 加起来 ≤100 字
|
||||||
- 不要打破当前场景的物理状态(玩家仍在原地、对面仍是同一个角色)
|
- 不要打破当前场景的物理状态(玩家仍在原地)
|
||||||
- 不要生成选项或下一步指引 —— 玩家点击会自然回到原 beat
|
- 不要生成选项或下一步指引 —— 玩家点击会自然回到原 beat
|
||||||
- 这个 beat 也要"有所得"——给玩家一个新细节、一丝潜台词或情绪波动(show, don't tell),别写成无意义的空台词
|
- 内容要"有所得"——一个新细节、一丝潜台词、一次真实的交流(show, don't tell)
|
||||||
|
|
||||||
speaker 字段允许的取值**只有两种**(与主路径 Writer 一致 — Pattern B galgame 标准):
|
speaker 字段允许的取值**只有两种**(与主路径 Writer 一致 — Pattern B galgame 标准):
|
||||||
1. **已登记角色**里的 NPC 真名(**绝不允许引入新角色**)
|
1. **已登记角色**里的 NPC 真名(**绝不允许引入新角色**)
|
||||||
2. **"你"** — 玩家本人在自言自语 / 说一句过渡性的话(对白框显示,但不调 TTS)
|
2. **"你"** — 玩家本人开口说话(对白框显示,但不调 TTS)
|
||||||
|
|
||||||
其它任何 POV 变体(玩家 / 我 / 主角 / protagonist / player / MC / I / me)**一律错误**,请用 "你" 代替。
|
其它任何 POV 变体(玩家 / 我 / 主角 / protagonist / player / MC / I / me)**一律错误**,请用 "你" 代替。
|
||||||
|
|
||||||
|
推荐模式(有 NPC 在场时):
|
||||||
|
narration = 描述玩家做了什么(动作/表情/心理)
|
||||||
|
speaker = NPC 真名
|
||||||
|
line = NPC 的回应台词
|
||||||
|
lineDelivery = 配音导演指令
|
||||||
|
|
||||||
- 如果有 line 且 speaker = NPC,**必须**给出 lineDelivery(配音导演指令)
|
- 如果有 line 且 speaker = NPC,**必须**给出 lineDelivery(配音导演指令)
|
||||||
- 如果有 line 且 speaker = "你",lineDelivery 可以留空(玩家对白不调 TTS)
|
- 如果有 line 且 speaker = "你",lineDelivery 可以留空(玩家对白不调 TTS)
|
||||||
|
|
||||||
@@ -913,6 +935,11 @@ export function buildInsertBeatUserMessage(
|
|||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push(`世界观:${session.worldSetting}`);
|
parts.push(`世界观:${session.worldSetting}`);
|
||||||
|
if (session.playerName) {
|
||||||
|
parts.push(
|
||||||
|
`玩家名字:${session.playerName}(NPC 对话时用此名字称呼玩家;speaker 字段仍固定为 "你" 不变)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (session.characters.length > 0) {
|
if (session.characters.length > 0) {
|
||||||
parts.push("\n已登记角色(speaker 只能用这些名字):");
|
parts.push("\n已登记角色(speaker 只能用这些名字):");
|
||||||
@@ -935,8 +962,17 @@ export function buildInsertBeatUserMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const lastBeatId2 = current.visitedBeatIds.at(-1) ?? current.scene.entryBeatId;
|
||||||
|
const lastBeat2 = current.scene.beats.find((b) => b.id === lastBeatId2);
|
||||||
|
const activeNpcs = lastBeat2?.activeCharacters?.map((c) => c.name) ?? [];
|
||||||
|
if (activeNpcs.length > 0) {
|
||||||
|
parts.push(`当前画面中在场的 NPC:${activeNpcs.join("、")}(优先让在场 NPC 回应玩家)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
parts.push(`\n玩家此刻的自由动作:${freeformAction}`);
|
parts.push(`\n玩家此刻的自由动作:${freeformAction}`);
|
||||||
parts.push("\n请生成一个过渡性 beat,严格以 JSON 格式返回。");
|
parts.push("\n请生成一个有实质回应的 beat,严格以 JSON 格式返回。");
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,4 +1007,41 @@ export function buildVisionUserPrompt(scene: Scene | null): string {
|
|||||||
红点位置即为玩家点击位置。请判断玩家意图与分类,以 JSON 格式返回。`;
|
红点位置即为玩家点击位置。请判断玩家意图与分类,以 JSON 格式返回。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Freeform Classify — classifies a player's freeform text input at a
|
||||||
|
// choice node into one of: match an existing choice, insert a beat
|
||||||
|
// in-scene, or trigger a scene change.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FREEFORM_CLASSIFY_SYSTEM = `你是交互视觉小说的意图分类助手。玩家在一个选择节点输入了自由文本(而非点击已有选项)。你要判断这个输入最适合走哪条路径:
|
||||||
|
|
||||||
|
1. "insert-beat":玩家想在当前场景内与角色互动(问一句话、做一个动作、表达情绪、调查某个东西)→ NPC 会对玩家的动作做出回应,但不切换场景
|
||||||
|
2. "change-scene":玩家想去别的地方、做出重大决定、推动剧情到新阶段 → 切换到全新场景
|
||||||
|
|
||||||
|
判断准则:
|
||||||
|
- 大多数对话类输入(问问题、说一句话、对角色做出反应)→ "insert-beat"
|
||||||
|
- 明确要离开当前场景、去别的地方、跳过时间、做出改变人物关系的重大决定 → "change-scene"
|
||||||
|
- 拿不准时偏向 "insert-beat"(场内互动成本低,体验更流畅)
|
||||||
|
|
||||||
|
必须输出严格 JSON:
|
||||||
|
{
|
||||||
|
"classify": "insert-beat" 或 "change-scene",
|
||||||
|
"freeformAction": "玩家想做什么的一句中文描述(用于后续编剧参考)"
|
||||||
|
}
|
||||||
|
|
||||||
|
不要输出 JSON 以外的任何文本。`;
|
||||||
|
|
||||||
|
export function buildFreeformClassifyUserMessage(
|
||||||
|
freeformText: string,
|
||||||
|
scenePrompt: string | undefined,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (scenePrompt) {
|
||||||
|
parts.push(`当前场景:${scenePrompt}`);
|
||||||
|
}
|
||||||
|
parts.push(`\n玩家输入:「${freeformText}」`);
|
||||||
|
parts.push("\n请判断分类,以 JSON 格式返回。");
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export type PainterCharacterInput = Pick<Character, "name" | "visualDescription">;
|
export type PainterCharacterInput = Pick<Character, "name" | "visualDescription">;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Gallery share-file crypto. AES-256-GCM via Web Crypto — same API in Node 22+
|
||||||
|
// (`globalThis.crypto`) and Cloudflare Workers, so the `runtime = "nodejs"`
|
||||||
|
// routes still port cleanly to the OpenNext / Cloudflare build later.
|
||||||
|
//
|
||||||
|
// Threat model:
|
||||||
|
// - Confidentiality: scene URLs + dialogue stay opaque to a casual recipient
|
||||||
|
// who isn't going through our server (can't curl the file and grep prompts).
|
||||||
|
// - Integrity: GCM's built-in auth tag means flipping any byte in the
|
||||||
|
// ciphertext or nonce makes subtle.decrypt throw — no separate HMAC needed.
|
||||||
|
// - NOT a replay defense: anyone with a valid file can replay it forever
|
||||||
|
// (this is intentional — it's a share-with-a-friend file, not an auth token).
|
||||||
|
//
|
||||||
|
// File layout (all big-endian, raw bytes):
|
||||||
|
// 0..3 "IFPL" magic — lets us refuse anything that's not ours
|
||||||
|
// 4 version (=1) bumped if the format ever changes
|
||||||
|
// 5..16 nonce (12 B) random per file; GCM requires non-repeating nonces
|
||||||
|
// per key (12-B random gives ~2^-32 collision risk at
|
||||||
|
// ~4B files — way more than this app will ever produce)
|
||||||
|
// 17.. ciphertext includes the 16-byte GCM auth tag at the end
|
||||||
|
//
|
||||||
|
// Key derivation: SHA-256 of the secret. We don't bother with HKDF/scrypt —
|
||||||
|
// the secret is already high-entropy (deployer-supplied 32+ char string),
|
||||||
|
// SHA-256 just normalizes it to AES-256's 32-byte key length.
|
||||||
|
|
||||||
|
const MAGIC = [0x49, 0x46, 0x50, 0x4c] as const; // "IFPL"
|
||||||
|
const VERSION = 1;
|
||||||
|
const NONCE_LEN = 12;
|
||||||
|
const HEADER_LEN = MAGIC.length + 1 + NONCE_LEN;
|
||||||
|
|
||||||
|
async function deriveKey(secret: string): Promise<CryptoKey> {
|
||||||
|
const material = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
);
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
material,
|
||||||
|
{ name: "AES-GCM" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function packDoc(
|
||||||
|
docStr: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const key = await deriveKey(secret);
|
||||||
|
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
||||||
|
const plaintext = new TextEncoder().encode(docStr);
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, plaintext),
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = new Uint8Array(HEADER_LEN + ciphertext.length);
|
||||||
|
out.set(MAGIC, 0);
|
||||||
|
out[MAGIC.length] = VERSION;
|
||||||
|
out.set(nonce, MAGIC.length + 1);
|
||||||
|
out.set(ciphertext, HEADER_LEN);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpackDoc(
|
||||||
|
blob: Uint8Array,
|
||||||
|
secret: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// 16 = minimum ciphertext length (auth tag alone, with empty plaintext)
|
||||||
|
if (blob.length < HEADER_LEN + 16) {
|
||||||
|
throw new Error("文件太小,不是合法的图集分享文件");
|
||||||
|
}
|
||||||
|
for (let i = 0; i < MAGIC.length; i++) {
|
||||||
|
if (blob[i] !== MAGIC[i]) {
|
||||||
|
throw new Error("文件格式不对,不是合法的图集分享文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const version = blob[MAGIC.length];
|
||||||
|
if (version !== VERSION) {
|
||||||
|
throw new Error(`图集分享文件版本不被支持: v${version}`);
|
||||||
|
}
|
||||||
|
const nonce = blob.slice(MAGIC.length + 1, HEADER_LEN);
|
||||||
|
const ciphertext = blob.slice(HEADER_LEN);
|
||||||
|
|
||||||
|
const key = await deriveKey(secret);
|
||||||
|
let plaintext: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: nonce },
|
||||||
|
key,
|
||||||
|
ciphertext,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// GCM auth tag failure → decryption refuses. Maps tamper + wrong-key both
|
||||||
|
// here, which is the right behavior: we can't distinguish, and neither
|
||||||
|
// should leak more than "this file isn't for this server".
|
||||||
|
throw new Error("文件校验失败:可能被改动过,或来自另一台部署");
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(plaintext);
|
||||||
|
}
|
||||||
+78
-14
@@ -8,23 +8,50 @@
|
|||||||
export const GENDERS = ["男性向", "女性向"] as const;
|
export const GENDERS = ["男性向", "女性向"] as const;
|
||||||
|
|
||||||
export const ART_STYLES = [
|
export const ART_STYLES = [
|
||||||
|
// 特殊选项
|
||||||
"自动",
|
"自动",
|
||||||
"自定义",
|
"自定义风格",
|
||||||
"京阿尼细腻日常",
|
// 日系动画
|
||||||
"新海诚唯美光影",
|
"京阿尼",
|
||||||
"Galgame CG",
|
"新海诚",
|
||||||
"3D 动漫电影",
|
"吉卜力",
|
||||||
|
"黑白漫画",
|
||||||
|
// 影视写实
|
||||||
|
"真实",
|
||||||
|
"3D 动画",
|
||||||
|
// 东方传统
|
||||||
|
"水墨",
|
||||||
|
"仙侠玄幻",
|
||||||
|
"浮世绘",
|
||||||
|
"敦煌壁画",
|
||||||
|
// 西方传统
|
||||||
|
"古典油画",
|
||||||
|
"莫奈",
|
||||||
|
"水彩",
|
||||||
|
"细密画",
|
||||||
|
"镶嵌画",
|
||||||
|
"彩绘玻璃",
|
||||||
|
// 题材氛围
|
||||||
"赛博朋克",
|
"赛博朋克",
|
||||||
|
"蒸汽朋克",
|
||||||
|
"哥特",
|
||||||
|
"废土",
|
||||||
|
"暗黑童话",
|
||||||
|
"都市幻想",
|
||||||
|
// 数字现代
|
||||||
|
"像素风",
|
||||||
"蒸汽波",
|
"蒸汽波",
|
||||||
"吉卜力治愈手绘",
|
"矢量插画",
|
||||||
"哥特庄园",
|
"低多边形",
|
||||||
"废土科幻",
|
"波普艺术",
|
||||||
// 以下为小众/区域性画风,留作长尾选项
|
"故障艺术",
|
||||||
"古典厚涂油画",
|
// 手绘手工
|
||||||
"极简中国水墨",
|
"彩铅",
|
||||||
"浮世绘木刻",
|
"手绘素描",
|
||||||
"莫高窟壁画",
|
"剪纸艺术",
|
||||||
"波斯细密画",
|
"儿童绘本",
|
||||||
|
"儿童涂鸦",
|
||||||
|
"黏土手工",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const PLOT_STYLES = ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"] as const;
|
export const PLOT_STYLES = ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"] as const;
|
||||||
@@ -35,3 +62,40 @@ export type Gender = (typeof GENDERS)[number];
|
|||||||
export type ArtStyle = (typeof ART_STYLES)[number];
|
export type ArtStyle = (typeof ART_STYLES)[number];
|
||||||
export type PlotStyle = (typeof PLOT_STYLES)[number];
|
export type PlotStyle = (typeof PLOT_STYLES)[number];
|
||||||
export type Pacing = (typeof PACINGS)[number];
|
export type Pacing = (typeof PACINGS)[number];
|
||||||
|
|
||||||
|
export const STYLE_MAP: Record<string, string> = {
|
||||||
|
"京阿尼": "Kyoto Animation anime style inspired by Beyond the Boundary and Sound Euphonium, precise thin line art with uniform weight, meticulous real-world architectural backgrounds with photographic accuracy, warm golden-hour lighting with soft bokeh and lens diffusion, iridescent color accents and crystalline light effects, delicate translucent gradients on hair and eyes, emotionally nuanced character expressions with subtle micro-expressions, rich ambient occlusion in indoor scenes.",
|
||||||
|
"新海诚": "Makoto Shinkai anime style, ultra-detailed photorealistic backgrounds with simplified anime characters, dramatic crepuscular rays and lens flare, vivid saturated sky gradients from deep blue to golden amber, volumetric cloud rendering, wet surface reflections, anamorphic bokeh highlights, cinematic widescreen composition.",
|
||||||
|
"吉卜力": "Studio Ghibli anime style inspired by Spirited Away and Howl's Moving Castle, hand-painted background art with lush visible brushstrokes, expansive skies with billowing cumulus clouds, warm earthy palette of moss green, ochre, and terracotta, gentle rounded character forms with expressive eyes, richly detailed natural environments with swaying grass and dappled light, a sense of magical wonder woven into everyday life.",
|
||||||
|
"3D 动画": "Cinematic 3D animated film style, Pixar-quality rendering with subsurface scattering on skin, volumetric god rays through atmospheric particles, physically-based material shading, warm filmic color grading, shallow depth of field with soft bokeh, expressive stylized character proportions.",
|
||||||
|
"真实": "Photorealistic cinematic style, natural lighting with soft directional key light, shallow depth of field with anamorphic bokeh, fine film grain texture, lifelike skin with pore-level detail and subsurface scattering, physically-based material rendering, subtle teal-and-orange color grading, 35mm lens perspective.",
|
||||||
|
"赛博朋克": "Cyberpunk anime illustration, neon-soaked urban nightscape, dominant palette of electric cyan, hot magenta, and deep indigo, hard-edged cel shading with sharp specular highlights, holographic signage reflections on wet asphalt, dense atmospheric haze with volumetric neon glow, high contrast between deep shadows and vivid accent lighting.",
|
||||||
|
"哥特": "Gothic romance illustration, dramatic Baroque chiaroscuro with deep shadow pools, cold moonlit rim lighting, muted palette of desaturated indigo, ash grey, and bone white, misty atmospheric perspective, ornate filigree and pointed-arch architectural details, melancholic and hauntingly beautiful mood.",
|
||||||
|
"废土": "Post-apocalyptic landscape illustration, weathered rough textures with rust, corrosion, and cracked concrete, muted dusty palette of burnt sienna, olive drab, and ash grey, hazy amber god-ray lighting through particulate atmosphere, overgrown vegetation reclaiming ruins, desolate yet strangely serene atmosphere.",
|
||||||
|
"像素风": "Pixel art illustration, crisp aliased edges with no anti-aliasing, limited 32-color palette with dithering for gradients, 16-bit era SNES aesthetic, clean tile-based composition, small carefully-placed specular highlights, retro video game atmosphere with warm CRT color warmth.",
|
||||||
|
"古典油画": "Classical oil painting in the academic tradition, rich impasto brushwork with visible palette-knife texture, dramatic Rembrandt lighting with warm chiaroscuro, sfumato blending at subject edges, Renaissance triangular composition, deep glaze layers producing luminous amber and umber tones, museum-quality varnished finish.",
|
||||||
|
"莫奈": "Impressionist painting in the style of Claude Monet, broken-color technique with visible dab brushstrokes, vibrant dappled sunlight filtering through foliage, complementary color shadows of lavender and cobalt, soft atmospheric perspective, plein-air natural palette of cerulean, viridian, and cadmium yellow, shimmering water reflections.",
|
||||||
|
"水彩": "Watercolor illustration on cold-pressed paper, wet-on-wet washes with soft pigment bleeding at edges, visible paper grain texture through translucent layers, granulation in cerulean and burnt sienna passages, intentional white paper reserves as highlights, gentle pastel tones with occasional saturated accents, dreamy luminous atmosphere.",
|
||||||
|
"水墨": "Traditional Chinese ink wash painting, expressive calligraphic brushstrokes with flying-white dry-brush texture (feibai), bold ink splashes contrasted with delicate fine-line detail, monochrome sumi ink with subtle indigo washes, expansive negative space evoking mist and void, sparse poetic composition following the principle of leave-blank (liu bai).",
|
||||||
|
"浮世绘": "Ukiyo-e Japanese woodblock print style, bold sumi-ink outlines with variable line weight, flat color areas with subtle wood-grain texture from printing, limited palette of indigo, vermilion, and ochre with key-block black, bokashi gradient shading technique, washi paper texture, elegant compositional asymmetry.",
|
||||||
|
"彩铅": "Colored pencil illustration on toned paper, fine directional hatching and cross-hatching strokes with visible pencil grain, burnished blending in highlight areas, warm cream paper tone showing through, soft layered color build-up from light to dark, delicate hand-drawn warmth with slight imperfections.",
|
||||||
|
"手绘素描": "Hand-drawn graphite pencil sketch, varied pressure producing light construction lines to deep tonal shading, visible eraser marks and smudge blending, off-white sketchbook paper texture, loose gestural composition with intentionally unfinished edges, raw artistic immediacy.",
|
||||||
|
"黑白漫画": "Black and white Japanese manga illustration, bold variable-weight ink outlines, extreme high-contrast with dense hatching and cross-hatching for tonal shading, screentone dot patterns for mid-tones, dramatic speed lines for motion, cinematic dynamic angles, stark chiaroscuro with no color gradients.",
|
||||||
|
"儿童绘本": "Children's picture book illustration, soft rounded shapes with friendly proportions, bright warm gouache-like palette of primary colors, clean even-weight outline art, simple readable compositions with clear focal points, whimsical cheerful atmosphere with gentle humor, inviting and safe visual tone.",
|
||||||
|
"儿童涂鸦": "Child's crayon and marker drawing style, naive unsteady strokes with wax-crayon texture, bold unmixed primary and secondary colors, cheerfully wrong perspective and scale, figures and objects floating freely on the page, scribbled sky and ground bands, playful uninhibited composition radiating pure joy.",
|
||||||
|
"黏土手工": "Claymation stop-motion animation style, soft rounded sculpted forms with visible fingerprint impressions and slight hand-sculpted imperfections, matte polymer clay texture with subtle surface grain, warm diffused three-point lighting on miniature set, tilt-shift shallow depth of field, charming handmade craft atmosphere.",
|
||||||
|
"敦煌壁画": "Dunhuang cave fresco style inspired by Mogao Grotto murals, figures rendered with flowing ribbon-like outlines and mineral pigment textures, muted oxidized palette of cinnabar red, malachite green, azurite blue, and ochre gold on aged stucco surface, warm torchlit ambiance with divine golden halos, flattened perspective with ornamental cloud and lotus motifs, celestial apsara grace and Buddhist iconographic composition, sacred and timelessly ancient atmosphere.",
|
||||||
|
"细密画": "Persian miniature painting style, ultra-fine brushwork with hair-thin outlines on ivory-smooth ground, flattened isometric perspective with no vanishing point, jewel-toned palette of lapis lazuli blue, ruby red, emerald green, and burnished gold leaf accents, intricate geometric and floral border ornamentation, cypress trees and tiled courtyard motifs, luminous and gem-like opulence.",
|
||||||
|
"镶嵌画": "Byzantine mosaic art style, image composed of thousands of tiny glass tesserae and gold smalti tiles, shimmering iridescent surface with visible tile gaps and grout lines, rich palette of deep cobalt blue, imperial purple, and radiant gold leaf backgrounds, figures rendered with large solemn frontal-facing eyes and flat iconic proportions, divine golden halos, sacred and monumental atmosphere.",
|
||||||
|
"彩绘玻璃": "Gothic stained glass window style, translucent jewel-colored panels of ruby red, sapphire blue, and emerald green glowing with backlit luminosity, bold black lead came lines dividing the composition into intricate segments, pointed-arch and rose-window framing, light streaming through glass casting prismatic color refractions, medieval cathedral craftsmanship, sacred and ethereally luminous atmosphere.",
|
||||||
|
"蒸汽波": "Vaporwave retro-digital aesthetic, nostalgic lo-fi palette of pastel pink, electric cyan, and soft lavender with neon magenta accents, grid-pattern floors receding into a synthetic purple sunset horizon, palm tree silhouettes and classical marble bust motifs, VHS scan-line artifacts and chromatic aberration glitches, smooth gradient skies with geometric shapes, dreamy retro-futuristic and melancholic nostalgia.",
|
||||||
|
"矢量插画": "Minimalist flat vector illustration, clean geometric shapes with crisp mathematically-perfect edges, bold even-weight outlines with no texture or brush artifacts, limited flat color palette with strategic use of negative space, strong silhouette-driven composition, subtle shadow layers for depth without gradients, modern graphic design sensibility with editorial illustration clarity.",
|
||||||
|
"低多边形": "Low poly 3D art style, faceted geometric surfaces built from flat triangular and polygonal faces with visible hard edges, simplified crystalline forms with ambient occlusion at polygon intersections, cool-toned palette of icy blue, soft teal, and muted violet with warm accent highlights, clean luminous rendering with soft environmental lighting, elegant digital origami aesthetic.",
|
||||||
|
"波普艺术": "Pop Art illustration in the style of Roy Lichtenstein and Andy Warhol, bold black outlines with flat high-saturation primary colors, Ben-Day halftone dot patterns for shading and skin tones, comic-book panel composition with speech-bubble framing, stark complementary color contrasts of red-yellow-blue, screen-printed repetition aesthetic, ironic and energetically vibrant commercial art atmosphere.",
|
||||||
|
"故障艺术": "Glitch art digital aesthetic, image corrupted with horizontal scan-line displacement and RGB channel splitting, vivid neon artifacts in electric cyan, hot magenta, and acid yellow against dark backgrounds, pixel-sorting streaks and data-moshing distortion bands, fragmented composition with broken grid alignment, CRT monitor phosphor glow, unsettling digital decay with a hypnotic cybernetic beauty.",
|
||||||
|
"剪纸艺术": "Multilayered papercut art style, intricate silhouette shapes cut from layered paper with visible paper edge thickness, soft diffused backlighting casting graduated shadows between layers, subtle paper fiber texture on cut surfaces, limited palette with depth created through staggered layer parallax, delicate negative-space filigree details, warm intimate craft atmosphere with three-dimensional shadow play.",
|
||||||
|
"蒸汽朋克": "Steampunk Victorian-industrial aesthetic, intricate brass clockwork gears, copper pipes, and riveted iron plating as core visual motifs, warm amber and burnished bronze palette with verdigris patina accents, gaslight and oil-lamp warm directional lighting with steam-diffused atmosphere, elaborate mechanical augmentation on characters, aged leather and polished wood textures, retro-futuristic Industrial Revolution grandeur.",
|
||||||
|
"仙侠玄幻": "Chinese xianxia fantasy illustration, ethereal qi energy rendered as luminous flowing wisps and aura effects, distant layered mountain silhouettes dissolving into celestial mist, palette of jade green, imperial gold, cinnabar red, and moonlit silver-blue, dynamic flowing robes and hair with wind-swept motion, celestial cloud formations and mythic creature motifs, mystical and transcendent atmosphere of cultivation and immortality.",
|
||||||
|
"暗黑童话": "Dark fairytale illustration in the Grimm Brothers tradition, towering twisted ancient trees with gnarled bark and claw-like branches, deep shadow-drenched forest with narrow shafts of pale moonlight, muted palette of moss black, bruised violet, and sickly yellow-green, ink-wash atmospheric fog at ground level, sinister hidden faces in bark and foliage textures, hauntingly beautiful atmosphere of dread and dark enchantment.",
|
||||||
|
"都市幻想": "Urban fantasy concept art, modern metropolitan cityscape with hidden magical elements bleeding through reality, glowing arcane sigils and spell circles overlaid on rain-streaked glass and concrete surfaces, palette blending cool urban greys and steel blue with warm magical amber and ethereal violet accents, characters in contemporary clothing channeling visible energy from their hands, liminal threshold between mundane and supernatural, mysterious and electrifying atmosphere of a secret world beneath the ordinary.",
|
||||||
|
};
|
||||||
|
|||||||
@@ -280,6 +280,12 @@ export type Session = {
|
|||||||
* share one aspect ratio. Absent → "landscape" (back-compat).
|
* share one aspect ratio. Absent → "landscape" (back-compat).
|
||||||
*/
|
*/
|
||||||
orientation?: Orientation;
|
orientation?: Orientation;
|
||||||
|
/**
|
||||||
|
* Optional player-chosen display name. When set, NPC dialogue will address
|
||||||
|
* the player by this name instead of the generic "你". Stored client-side
|
||||||
|
* only (localStorage); never persisted server-side.
|
||||||
|
*/
|
||||||
|
playerName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -372,6 +378,8 @@ export type StartRequest = {
|
|||||||
* (default) keeps 16:9 widescreen. Locked for the whole session.
|
* (default) keeps 16:9 widescreen. Locked for the whole session.
|
||||||
*/
|
*/
|
||||||
orientation?: Orientation;
|
orientation?: Orientation;
|
||||||
|
/** Optional player display name — see Session.playerName. */
|
||||||
|
playerName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// /api/parse-style-image — vision LLM extracts a textual painting-style
|
// /api/parse-style-image — vision LLM extracts a textual painting-style
|
||||||
@@ -458,6 +466,21 @@ export type VisionResponse = {
|
|||||||
classify: VisionClassify;
|
classify: VisionClassify;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// /api/classify-freeform — classifies a player's freeform text input
|
||||||
|
// into one of three paths: match an existing choice, insert a beat
|
||||||
|
// in-scene, or trigger a scene change.
|
||||||
|
export type FreeformClassifyRequest = {
|
||||||
|
session: Session;
|
||||||
|
freeformText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FreeformClassify = "insert-beat" | "change-scene";
|
||||||
|
|
||||||
|
export type FreeformClassifyResponse = {
|
||||||
|
classify: FreeformClassify;
|
||||||
|
freeformAction: string;
|
||||||
|
};
|
||||||
|
|
||||||
// /api/insert-beat — generates a single transient beat in response to
|
// /api/insert-beat — generates a single transient beat in response to
|
||||||
// a freeform vision action. Does NOT regenerate the image.
|
// a freeform vision action. Does NOT regenerate the image.
|
||||||
export type InsertBeatRequest = {
|
export type InsertBeatRequest = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
|
output: process.env.BUILD_STANDALONE === "true" ? "standalone" : undefined,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
typedRoutes: false,
|
typedRoutes: false,
|
||||||
turbopack: {
|
turbopack: {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user