From b49d703e3cd33cafdc91930eca427f7978e1552d Mon Sep 17 00:00:00 2001 From: Song367 <601337784@qq.com> Date: Wed, 11 Mar 2026 21:53:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E9=94=AE=E8=BD=AC=E6=8D=A2=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-03-11-account-sync-drafts.md | 156 ++ .../2026-03-11-audience-word-range-design.md | 25 + docs/plans/2026-03-11-audience-word-range.md | 97 + ...26-03-11-upload-extract-episodes-design.md | 44 + .../2026-03-11-upload-extract-episodes.md | 109 ++ package-lock.json | 397 +++++ package.json | 5 +- server/auth.ts | 85 + server/db.ts | 102 ++ server/index.ts | 91 + server/types.ts | 10 + src/App.tsx | 1586 +++++++++++++++-- src/lib/conversionDraft.ts | 42 + src/lib/conversionWorkflow.ts | 43 + src/lib/scriptPreview.ts | 17 + src/services/ai.ts | 337 +++- src/services/api.ts | 60 + src/services/fileParsing.ts | 71 + 18 files changed, 3083 insertions(+), 194 deletions(-) create mode 100644 docs/plans/2026-03-11-account-sync-drafts.md create mode 100644 docs/plans/2026-03-11-audience-word-range-design.md create mode 100644 docs/plans/2026-03-11-audience-word-range.md create mode 100644 docs/plans/2026-03-11-upload-extract-episodes-design.md create mode 100644 docs/plans/2026-03-11-upload-extract-episodes.md create mode 100644 server/auth.ts create mode 100644 server/db.ts create mode 100644 server/index.ts create mode 100644 server/types.ts create mode 100644 src/lib/conversionDraft.ts create mode 100644 src/lib/conversionWorkflow.ts create mode 100644 src/lib/scriptPreview.ts create mode 100644 src/services/api.ts create mode 100644 src/services/fileParsing.ts diff --git a/docs/plans/2026-03-11-account-sync-drafts.md b/docs/plans/2026-03-11-account-sync-drafts.md new file mode 100644 index 0000000..826ee05 --- /dev/null +++ b/docs/plans/2026-03-11-account-sync-drafts.md @@ -0,0 +1,156 @@ +# Account Sync Drafts Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add lightweight account/password auth, cloud draft persistence, cross-device restore, resumable episode conversion, and full-regenerate controls. + +**Architecture:** Keep the current Vite frontend, add a minimal Express + SQLite backend under `server/`, and let the frontend auto-save/load the current draft for the logged-in account. Continue using the existing conversion UI, but persist the generated episode results and resume state remotely. + +**Tech Stack:** React 19, TypeScript, Vite, Express, better-sqlite3, Node crypto, SQLite + +--- + +### Task 1: Create backend skeleton and shared storage model + +**Files:** +- Create: `server/index.ts` +- Create: `server/db.ts` +- Create: `server/auth.ts` +- Modify: `package.json` +- Modify: `vite.config.ts` + +**Step 1: Add a backend run script** +- Add `dev:server` and `dev:full` scripts. +- Keep existing frontend scripts unchanged. + +**Step 2: Create SQLite schema helpers** +- Add `users`, `sessions`, and `drafts` tables. +- Store one current draft JSON blob per user for now. + +**Step 3: Add Express server bootstrap** +- Enable JSON body parsing. +- Mount auth and draft endpoints under `/api`. + +**Step 4: Add Vite proxy for local development** +- Proxy `/api` to the local backend server. + +**Step 5: Verify backend compiles under TypeScript** +- Run `npm run lint`. + +### Task 2: Implement lightweight account/password authentication + +**Files:** +- Modify: `server/db.ts` +- Modify: `server/auth.ts` +- Modify: `server/index.ts` + +**Step 1: Implement password hashing with Node crypto** +- Use `scryptSync` + random salt. + +**Step 2: Add register endpoint** +- Accept `username` and `password`. +- Reject duplicate usernames and invalid payloads. + +**Step 3: Add login endpoint** +- Verify password. +- Create a random session token and persist it. + +**Step 4: Add auth middleware** +- Read bearer token. +- Resolve current user from `sessions`. + +**Step 5: Add session restore endpoint** +- Return current user info when token is valid. + +### Task 3: Add cloud draft persistence endpoints + +**Files:** +- Modify: `server/db.ts` +- Modify: `server/index.ts` +- Create: `server/types.ts` + +**Step 1: Define draft payload shape** +- Include conversion settings, extracted episodes, conversion episode results, finalized selections, and source metadata. + +**Step 2: Add load current draft endpoint** +- Return the saved draft JSON for the authenticated user. + +**Step 3: Add save current draft endpoint** +- Upsert the latest draft JSON and update timestamp. + +**Step 4: Add reset/restart endpoint if needed** +- Allow frontend to intentionally replace cloud draft on full regeneration. + +### Task 4: Add frontend auth state and draft synchronization + +**Files:** +- Create: `src/services/api.ts` +- Modify: `src/App.tsx` +- Modify: `src/main.tsx` if needed + +**Step 1: Add auth modal/panel UI** +- Support register and login with username/password. + +**Step 2: Add token persistence** +- Save token locally and restore session on app load. + +**Step 3: Load remote draft on login/app restore** +- Hydrate the conversion state from the server. + +**Step 4: Auto-save remote draft** +- Debounce save calls when relevant conversion state changes. +- Save generated content and manual edits. + +### Task 5: Strengthen resumable conversion workflow + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/services/ai.ts` +- Modify: `src/lib/conversionWorkflow.ts` +- Modify: `tests/conversionWorkflow.test.ts` + +**Step 1: Preserve current stop/resume behavior** +- Keep paused episode/version state intact. + +**Step 2: Add `重新全部生成` control** +- When a resumable state exists, show both `继续续集生成` and `重新全部生成`. +- Full regenerate clears existing generated episode results before starting from episode 1. + +**Step 3: Ensure manual edits persist** +- Save edited episode version content into draft state and remote storage. + +### Task 6: Enforce continuity and scope constraints in generation + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/services/ai.ts` + +**Step 1: Build previous-episode continuity context** +- For episode N, pass episode N-1 selected final version, or current active version if not finalized. + +**Step 2: Restrict generation scope** +- Explicitly tell the model to only generate within the current episode outline shown on the left. + +**Step 3: Reassert global constraints** +- Require adherence to worldview, story outline, and core character settings. + +**Step 4: Keep resume continuation safe** +- Continue from already generated content without rewriting earlier text. + +### Task 7: Verification + +**Files:** +- Test: `tests/conversionWorkflow.test.ts` +- Test: backend endpoint smoke verification via local commands + +**Step 1: Run draft workflow unit test** +- Run `node --experimental-strip-types tests/conversionWorkflow.test.ts` + +**Step 2: Run type-check** +- Run `npm run lint` + +**Step 3: Run production build** +- Run `npm run build` + +**Step 4: Smoke-test backend boot if possible** +- Start local backend and verify auth/draft endpoints respond. diff --git a/docs/plans/2026-03-11-audience-word-range-design.md b/docs/plans/2026-03-11-audience-word-range-design.md new file mode 100644 index 0000000..5a2791c --- /dev/null +++ b/docs/plans/2026-03-11-audience-word-range-design.md @@ -0,0 +1,25 @@ +锘# Audience And Word Range Design + +**Context** + +The app already exposes shared generation controls for script type, themes, and narrative methods. Both conversion mode and creation mode use those controls and persist them locally. + +**Decision** + +Add two more shared controls in both modes: + +- `鍙椾紬鍊惧悜`: `鐢烽` and `濂抽`, default `鐢烽` +- `瀛楁暟鑼冨洿`: `200 - 500`, `500 - 1000`, `1000 - 2000`, `2000 - 3000`, `3000浠ヤ笂`, `涓嶉檺`, default `涓嶉檺` + +These controls should: + +- appear in both modes alongside the existing generation parameters +- use the same pill-button interaction style as the existing filters +- persist via `localStorage` +- be included in all AI generation requests for conversion, single-page creation, and batch creation + +**Implementation Notes** + +- Keep the values in Chinese only. +- Follow the current state structure in `src/App.tsx` instead of doing a broader settings refactor. +- Extend the AI settings payload passed into `src/services/ai.ts` so prompts can explicitly mention the new choices. diff --git a/docs/plans/2026-03-11-audience-word-range.md b/docs/plans/2026-03-11-audience-word-range.md new file mode 100644 index 0000000..03278de --- /dev/null +++ b/docs/plans/2026-03-11-audience-word-range.md @@ -0,0 +1,97 @@ +锘# Audience And Word Range Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add shared audience and word-range controls to both modes, persist them locally, and include them in every script generation request. + +**Architecture:** Extend `src/App.tsx` with two new shared state values and reusable option lists, render matching controls in both parameter areas, and thread the new values through the existing AI request payloads. Update `src/services/ai.ts` to accept the expanded settings object and append the new fields to prompts. + +**Tech Stack:** React 19, TypeScript, Vite + +--- + +### Task 1: Add shared state and persistence + +**Files:** +- Modify: `src/App.tsx` + +**Step 1: Add the failing test surrogate** + +There is no existing automated test harness in this repo, so use TypeScript as the first guard by introducing the new state and payload types in a way that will initially break downstream call sites until they are wired through. + +**Step 2: Verify the break** + +Run: `npm run lint` +Expected: FAIL until all new settings usages are connected. + +**Step 3: Write minimal implementation** + +- Add `AudiencePreference` and `WordRange` union types. +- Add shared option arrays. +- Add `useState` hooks with Chinese defaults. +- Persist them with `localStorage`. + +**Step 4: Verify** + +Run: `npm run lint` +Expected: still failing until AI payload changes are completed. + +### Task 2: Render controls in both modes + +**Files:** +- Modify: `src/App.tsx` + +**Step 1: Add UI in conversion mode** + +- Insert `鍙椾紬鍊惧悜` and `瀛楁暟鑼冨洿` rows into the expanded conversion settings panel. + +**Step 2: Add UI in creation mode** + +- Insert the same two rows into the creation top filter area. + +**Step 3: Keep styling consistent** + +- Reuse the existing pill-button class patterns. +- Ensure the labels remain Chinese-only. + +### Task 3: Thread settings into AI generation + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/services/ai.ts` + +**Step 1: Expand payload shape** + +- Add `audiencePreference` and `wordRange` to the existing global settings object passed into generation functions. + +**Step 2: Update all call sites** + +- `convertTextToScript` +- `generatePageScript` +- `generateAllScripts` + +**Step 3: Update prompts** + +- Mention audience preference and target word range in each generated prompt alongside worldview, outline, and characters. + +### Task 4: Verify + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/services/ai.ts` + +**Step 1: Run type check** + +Run: `npm run lint` +Expected: PASS + +**Step 2: Run production build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Review** + +- Confirm both modes show the two controls. +- Confirm defaults are `鐢烽` and `涓嶉檺`. +- Confirm no English values were introduced. diff --git a/docs/plans/2026-03-11-upload-extract-episodes-design.md b/docs/plans/2026-03-11-upload-extract-episodes-design.md new file mode 100644 index 0000000..141d7dc --- /dev/null +++ b/docs/plans/2026-03-11-upload-extract-episodes-design.md @@ -0,0 +1,44 @@ +锘# Upload And Extract Episodes Design + +**Context** + +The conversion mode currently accepts only manual text input in the left textarea. The app already has a Doubao streaming integration pattern for script generation, and the extracted content should feed back into the existing `sourceText` flow rather than replacing the rest of the conversion pipeline. + +**Decision** + +Adopt a client-side upload flow for four file types: Word (`.docx`), text (`.txt`), PDF (`.pdf`), and Markdown (`.md`). After upload, the app will read the file in the browser, send the raw text to a new Doubao extraction call using `doubao-seed-1-6-flash-250828`, and stream the model output directly into the left-side source textarea. + +**Behavior** + +- The source input area becomes a hybrid input surface: manual typing still works, and file upload is added alongside it. +- Upload immediately starts extraction without requiring the user to click `绔嬪嵆杞崲鎴愬墽鏈琡. +- The extraction model is instructed to identify each episode and return the original script content 1:1 with no rewriting, normalization, cleanup, or omission. +- The streamed extraction result overwrites `sourceText` progressively so the user can see the result arrive in real time. +- Existing conversion generation stays separate. After extraction completes, the user can still click the existing conversion button to continue with the current workflow. + +**Parsing Strategy** + +- `.txt` and `.md`: read with `File.text()`. +- `.docx`: parse in-browser with a document-text extraction library. +- `.pdf`: parse in-browser with a PDF text extraction library. + +**UI And State** + +- Add upload affordance, accepted-file hint, extraction loading state, and extraction error state in conversion mode. +- Preserve `sourceText` local persistence. +- Keep manual editing enabled after extraction. + +**AI Contract** + +The new extraction API will: + +- use Doubao only +- stream results +- instruct the model to output episode-separated original content only +- avoid any transformations beyond episode boundary recognition + +**Risks** + +- PDF text extraction quality depends on document structure. +- Even with strict prompting, model-based extraction is probabilistic, so the prompt must strongly prohibit edits and define a deterministic output format. +- Browser-side parsing adds dependency and bundle-size cost. diff --git a/docs/plans/2026-03-11-upload-extract-episodes.md b/docs/plans/2026-03-11-upload-extract-episodes.md new file mode 100644 index 0000000..4aa6b20 --- /dev/null +++ b/docs/plans/2026-03-11-upload-extract-episodes.md @@ -0,0 +1,109 @@ +锘# Upload And Extract Episodes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add file upload for `.docx`, `.txt`, `.pdf`, and `.md` in conversion mode, stream a Doubao-based episode extraction result into the source textarea, and preserve the existing conversion workflow. + +**Architecture:** Extend the conversion UI in `src/App.tsx` with upload controls and extraction state, add browser-side file parsing helpers for the four formats, and create a new streaming Doubao extraction method in `src/services/ai.ts`. The extraction result should progressively replace `sourceText`, then hand control back to the user for the existing conversion step. + +**Tech Stack:** React 19, TypeScript, Vite, OpenAI SDK, browser file APIs, PDF/docx parsing libraries + +--- + +### Task 1: Add dependency and extraction API surface + +**Files:** +- Modify: `package.json` +- Modify: `src/services/ai.ts` + +**Step 1: Write the failing test surrogate** + +Use TypeScript as the first guard by adding a new extraction function signature and references from `App.tsx` before implementation is complete. + +**Step 2: Run verification to confirm the break** + +Run: `npm run lint` +Expected: FAIL because the new extraction path is not fully wired. + +**Step 3: Write minimal implementation** + +- Add any needed client-side parsing dependencies. +- Add `extractEpisodesFromSource` in `src/services/ai.ts`. +- Use model `doubao-seed-1-6-flash-250828`. +- Stream chunks through a callback similar to the existing conversion flow. +- Build a strict prompt requiring 1:1 episode/script extraction with no edits. + +**Step 4: Re-run verification** + +Run: `npm run lint` +Expected: still failing until UI wiring is complete. + +### Task 2: Add browser-side file parsing helpers + +**Files:** +- Create: `src/services/fileParsing.ts` +- Modify: `src/App.tsx` + +**Step 1: Write the failing test surrogate** + +Reference helper APIs from `App.tsx` before the helper module is complete. + +**Step 2: Implement minimal parsing** + +- Support `.txt` and `.md` via plain text reads. +- Support `.docx` via browser text extraction. +- Support `.pdf` via browser PDF text extraction. +- Return normalized raw text for LLM input without attempting episode splitting locally. + +**Step 3: Handle unsupported files** + +- Reject anything outside the four allowed extensions. +- Return actionable error messages. + +### Task 3: Wire upload UX into conversion mode + +**Files:** +- Modify: `src/App.tsx` + +**Step 1: Add state** + +- Add extraction progress/loading/error state. +- Keep `sourceText` persistence unchanged. + +**Step 2: Add UI** + +- Add file upload control near the source textarea. +- Show supported formats. +- Show extraction-in-progress feedback distinct from the existing conversion action. + +**Step 3: Add upload flow** + +- On file select, parse the file. +- Start the streaming extraction call immediately. +- Replace `sourceText` incrementally with streamed content. +- Preserve manual editing after completion. + +### Task 4: Verify behavior and guardrails + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/services/ai.ts` +- Modify: `package.json` + +**Step 1: Run type check** + +Run: `npm run lint` +Expected: PASS + +**Step 2: Run build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Manual review checklist** + +- Upload accepts only `.docx`, `.txt`, `.pdf`, `.md`. +- Upload starts extraction immediately. +- Left source textarea updates in streaming fashion. +- Extraction uses the raw-output contract and does not intentionally rewrite content. +- Existing `绔嬪嵆杞崲鎴愬墽鏈琡 button still works after extraction. diff --git a/package-lock.json b/package-lock.json index 6346438..be38936 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,10 @@ "express": "^4.21.2", "file-saver": "^2.0.5", "lucide-react": "^0.546.0", + "mammoth": "^1.11.0", "motion": "^12.23.24", "openai": "^6.27.0", + "pdfjs-dist": "^5.5.207", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", @@ -801,6 +803,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.96.tgz", + "integrity": "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.96", + "@napi-rs/canvas-darwin-arm64": "0.1.96", + "@napi-rs/canvas-darwin-x64": "0.1.96", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.96", + "@napi-rs/canvas-linux-arm64-musl": "0.1.96", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.96", + "@napi-rs/canvas-linux-x64-gnu": "0.1.96", + "@napi-rs/canvas-linux-x64-musl": "0.1.96", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.96", + "@napi-rs/canvas-win32-x64-msvc": "0.1.96" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.96.tgz", + "integrity": "sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.96.tgz", + "integrity": "sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.96.tgz", + "integrity": "sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.96.tgz", + "integrity": "sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.96.tgz", + "integrity": "sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.96.tgz", + "integrity": "sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.96.tgz", + "integrity": "sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.96.tgz", + "integrity": "sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.96.tgz", + "integrity": "sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.96.tgz", + "integrity": "sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.96.tgz", + "integrity": "sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1724,6 +1991,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1770,6 +2046,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1904,6 +2189,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -2348,6 +2639,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/docx": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.0.tgz", @@ -2410,6 +2707,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3689,6 +3995,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3716,6 +4033,30 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mammoth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4579,6 +4920,13 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -4639,6 +4987,12 @@ } } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -4698,6 +5052,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4735,6 +5098,19 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.5.207", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", + "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.95", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5399,6 +5775,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5721,6 +6103,12 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6596,6 +6984,15 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 225ea2e..1067a79 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "dev:server": "node --experimental-strip-types server/index.ts" }, "dependencies": { "@google/genai": "^1.29.0", @@ -21,8 +22,10 @@ "express": "^4.21.2", "file-saver": "^2.0.5", "lucide-react": "^0.546.0", + "mammoth": "^1.11.0", "motion": "^12.23.24", "openai": "^6.27.0", + "pdfjs-dist": "^5.5.207", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..1eb6587 --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,85 @@ +import crypto from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; +import { createSession, createUser, deleteSession, findUserBySessionToken, findUserByUsername } from './db.ts'; +import type { AuthenticatedUser } from './types.ts'; + +declare global { + namespace Express { + interface Request { + authUser?: AuthenticatedUser; + authToken?: string; + } + } +} + +export function hashPassword(password: string, salt = crypto.randomBytes(16).toString('hex')) { + const hash = crypto.scryptSync(password, salt, 64).toString('hex'); + return { hash, salt }; +} + +export function verifyPassword(password: string, passwordHash: string, passwordSalt: string) { + const { hash } = hashPassword(password, passwordSalt); + return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(passwordHash, 'hex')); +} + +export function issueSession(userId: number) { + const token = crypto.randomBytes(32).toString('hex'); + createSession(token, userId); + return token; +} + +export function registerUser(username: string, password: string) { + if (findUserByUsername(username)) { + throw new Error('账号已存在'); + } + const { hash, salt } = hashPassword(password); + const user = createUser(username, hash, salt); + if (!user) { + throw new Error('账号创建失败'); + } + const token = issueSession(user.id); + return { + token, + user: { id: user.id, username: user.username }, + }; +} + +export function loginUser(username: string, password: string) { + const user = findUserByUsername(username); + if (!user || !verifyPassword(password, user.password_hash, user.password_salt)) { + throw new Error('账号或密码错误'); + } + const token = issueSession(user.id); + return { + token, + user: { id: user.id, username: user.username }, + }; +} + +export function requireAuth(req: Request, res: Response, next: NextFunction) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + if (!token) { + res.status(401).json({ error: '未登录' }); + return; + } + + const user = findUserBySessionToken(token); + if (!user) { + res.status(401).json({ error: '登录已失效' }); + return; + } + + req.authToken = token; + req.authUser = { id: user.id, username: user.username }; + next(); +} + +export function resolveSession(token: string) { + const user = findUserBySessionToken(token); + return user ? { id: user.id, username: user.username } : null; +} + +export function logoutSession(token: string) { + deleteSession(token); +} diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..c021a42 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,102 @@ +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; + +const dataDir = path.resolve(process.cwd(), '.data'); +fs.mkdirSync(dataDir, { recursive: true }); + +const dbPath = path.join(dataDir, 'scriptflow.db'); +export const db = new Database(dbPath); + +db.pragma('journal_mode = WAL'); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS drafts ( + user_id INTEGER PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); + +export type UserRow = { + id: number; + username: string; + password_hash: string; + password_salt: string; +}; + +export function findUserByUsername(username: string) { + return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined; +} + +export function findUserById(id: number) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined; +} + +export function createUser(username: string, passwordHash: string, passwordSalt: string) { + const result = db + .prepare('INSERT INTO users (username, password_hash, password_salt) VALUES (?, ?, ?)') + .run(username, passwordHash, passwordSalt); + return findUserById(Number(result.lastInsertRowid)); +} + +export function createSession(token: string, userId: number) { + db.prepare('INSERT INTO sessions (token, user_id) VALUES (?, ?)').run(token, userId); +} + +export function findUserBySessionToken(token: string) { + return db + .prepare(` + SELECT users.* + FROM sessions + INNER JOIN users ON users.id = sessions.user_id + WHERE sessions.token = ? + `) + .get(token) as UserRow | undefined; +} + +export function deleteSession(token: string) { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); +} + +export function getDraftForUser(userId: number) { + const row = db.prepare('SELECT payload, updated_at FROM drafts WHERE user_id = ?').get(userId) as { payload: string; updated_at: string } | undefined; + if (!row) return null; + return { + payload: JSON.parse(row.payload), + updatedAt: row.updated_at, + }; +} + +export function saveDraftForUser(userId: number, payload: unknown) { + const serialized = JSON.stringify(payload); + const updatedAt = new Date().toISOString(); + db + .prepare(` + INSERT INTO drafts (user_id, payload, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at + `) + .run(userId, serialized, updatedAt); + + return { + payload, + updatedAt, + }; +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..1e3a06c --- /dev/null +++ b/server/index.ts @@ -0,0 +1,91 @@ +import express from 'express'; +import { getDraftForUser, saveDraftForUser } from './db.ts'; +import { loginUser, logoutSession, registerUser, requireAuth, resolveSession } from './auth.ts'; +import type { DraftPayload } from './types.ts'; + +const app = express(); +const port = Number(process.env.PORT || 8787); + +app.use(express.json({ limit: '10mb' })); + +app.get('/api/health', (_req, res) => { + res.json({ ok: true }); +}); + +app.post('/api/auth/register', (req, res) => { + const username = String(req.body?.username || '').trim(); + const password = String(req.body?.password || ''); + + if (username.length < 3 || password.length < 6) { + res.status(400).json({ error: '账号至少 3 位,密码至少 6 位' }); + return; + } + + try { + const result = registerUser(username, password); + res.json(result); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : '注册失败' }); + } +}); + +app.post('/api/auth/login', (req, res) => { + const username = String(req.body?.username || '').trim(); + const password = String(req.body?.password || ''); + + if (!username || !password) { + res.status(400).json({ error: '请输入账号和密码' }); + return; + } + + try { + const result = loginUser(username, password); + res.json(result); + } catch (error) { + res.status(401).json({ error: error instanceof Error ? error.message : '登录失败' }); + } +}); + +app.get('/api/auth/session', (req, res) => { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + if (!token) { + res.status(401).json({ error: '未登录' }); + return; + } + + const user = resolveSession(token); + if (!user) { + res.status(401).json({ error: '登录已失效' }); + return; + } + + res.json({ user }); +}); + +app.post('/api/auth/logout', requireAuth, (req, res) => { + if (req.authToken) { + logoutSession(req.authToken); + } + res.json({ ok: true }); +}); + +app.get('/api/draft/current', requireAuth, (req, res) => { + const draft = getDraftForUser(req.authUser!.id); + res.json({ draft }); +}); + +app.post('/api/draft/current', requireAuth, (req, res) => { + const payload = req.body?.draft as DraftPayload | undefined; + if (!payload || typeof payload !== 'object') { + res.status(400).json({ error: '草稿内容无效' }); + return; + } + + const draft = saveDraftForUser(req.authUser!.id, payload); + res.json({ draft }); +}); + +app.listen(port, '127.0.0.1', () => { + console.log(`ScriptFlow server listening on http://127.0.0.1:${port}`); +}); diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 0000000..34c95e0 --- /dev/null +++ b/server/types.ts @@ -0,0 +1,10 @@ +export interface DraftPayload { + version: number; + savedAt: string; + data: Record; +} + +export interface AuthenticatedUser { + id: number; + username: string; +} diff --git a/src/App.tsx b/src/App.tsx index e076a8d..75f22b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { Download, RefreshCw, Edit3, + Upload, ChevronRight, ChevronLeft, ChevronUp, @@ -38,14 +39,29 @@ import { refineConvertedScript, generatePageScript, generateAllScripts, + extractEpisodesFromSource, + extractPlotBackgroundFromSource, ScriptOption, AIConfig } from './services/ai'; +import { parseUploadedSourceFile } from './services/fileParsing'; +import { getResumeStartIndex, getVersionsToGenerate, isConversionAbortedError } from './lib/conversionWorkflow'; +import { AuthUser, loadCurrentDraft, login, logout, register, restoreSession, saveCurrentDraft } from './services/api'; +import { splitScriptPreviewBlocks } from './lib/scriptPreview'; +import { parseSavedConversionEpisodeResults, sanitizeSavedConversionEpisodeResults } from './lib/conversionDraft'; -type ScriptType = '鐭墽' | '鐢佃鍓' | '鐢靛奖'; +type ScriptType = '\u77ed\u5267' | '\u7535\u89c6\u5267' | '\u7535\u5f71'; type ScriptFormat = '鍏ㄩ儴' | '鍦烘櫙' | '浜虹墿' | '鍙拌瘝' | '鍔ㄤ綔' | '闀滃ご鎻愮ず'; type ThemeLabel = '鎮枒' | '渚︽帰' | '鐘姜' | '鍙嶈浆' | '鎯婃倸' | '鎺ㄧ悊'; -type NarrativeMethod = '椤哄彊' | '鍊掑彊' | '鎻掑彊' | '绗竴浜虹О' | '绗笁浜虹О鍏ㄧ煡 / 闄愮煡' | '澶氱嚎鍙欎簨' | '鐜舰鍙欎簨' | '钂欏お濂' | '鐣欑櫧鍙欎簨' | '鎮康鍙欎簨'; +type NarrativeMethod = '\u987a\u53d9' | '\u5012\u53d9' | '\u63d2\u53d9' | '\u7b2c\u4e00\u4eba\u79f0' | '\u7b2c\u4e09\u4eba\u79f0\u5168\u77e5 / \u9650\u77e5' | '\u591a\u7ebf\u53d9\u4e8b' | '\u73af\u5f62\u53d9\u4e8b' | '\u8499\u592a\u5947' | '\u7559\u767d\u53d9\u4e8b' | '\u60ac\u5ff5\u53d9\u4e8b'; +type AudiencePreference = '\u7537\u9891' | '\u5973\u9891'; +type WordRange = '200 - 500' | '500 - 1000' | '1000 - 2000' | '2000 - 3000' | '3000\u4ee5\u4e0a' | '\u4e0d\u9650'; + +const SCRIPT_TYPES: ScriptType[] = ['\u77ed\u5267', '\u7535\u89c6\u5267', '\u7535\u5f71']; +const THEME_OPTIONS: ThemeLabel[] = ['\u60ac\u7591', '\u4fa6\u63a2', '\u72af\u7f6a', '\u53cd\u8f6c', '\u60ca\u609a', '\u63a8\u7406']; +const NARRATIVE_OPTIONS: NarrativeMethod[] = ['\u987a\u53d9', '\u5012\u53d9', '\u63d2\u53d9', '\u7b2c\u4e00\u4eba\u79f0', '\u7b2c\u4e09\u4eba\u79f0\u5168\u77e5 / \u9650\u77e5', '\u591a\u7ebf\u53d9\u4e8b', '\u73af\u5f62\u53d9\u4e8b', '\u8499\u592a\u5947', '\u7559\u767d\u53d9\u4e8b', '\u60ac\u5ff5\u53d9\u4e8b']; +const AUDIENCE_OPTIONS: AudiencePreference[] = ['\u7537\u9891', '\u5973\u9891']; +const WORD_RANGE_OPTIONS: WordRange[] = ['200 - 500', '500 - 1000', '1000 - 2000', '2000 - 3000', '3000\u4ee5\u4e0a', '\u4e0d\u9650']; interface CreationPage { id: string; @@ -54,6 +70,240 @@ interface CreationPage { selectedScriptIndex: number | null; } +interface ExtractedEpisode { + id: string; + title: string; + content: string; + wordCount: number; +} + +type ConversionVersionStatus = 'pending' | 'generating' | 'completed' | 'failed' | 'paused'; + +interface ConversionEpisodeVersion { + id: '0' | '1'; + label: 'A' | 'B'; + content: string; + status: ConversionVersionStatus; +} + +interface ConversionEpisodeResult { + id: string; + title: string; + sourceContent: string; + status: 'pending' | 'generating' | 'completed' | 'failed' | 'paused'; + activeVersionId: '0' | '1'; + selectedFinalVersionId: '0' | '1' | null; + versions: ConversionEpisodeVersion[]; + error?: string; +} + +function countWords(value: string) { + return value.replace(/\s+/g, '').length; +} + +function parseEpisodesFromText(text: string): ExtractedEpisode[] { + const normalized = text.replace(/\r\n/g, '\n').trim(); + if (!normalized) return []; + + const lines = normalized.split('\n'); + const episodePrefixPattern = /^(\u7b2c\s*[0-9\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u96f6\u4e24]+\s*\u96c6)(.*)$/; + const episodes: Array<{ title: string; content: string }> = []; + let currentTitle = ''; + let currentLines: string[] = []; + + const pushCurrent = () => { + const content = currentLines.join('\n').trim(); + if (!currentTitle && !content) return; + episodes.push({ title: currentTitle || '\u672a\u8bc6\u522b\u96c6\u6570', content }); + }; + + const splitEpisodeHeading = (line: string) => { + const match = line.match(episodePrefixPattern); + if (!match) return null; + + const prefix = match[1].trim(); + const remainder = match[2].trim(); + if (!remainder) { + return { title: prefix, inlineContent: '' }; + } + + const normalizedRemainder = remainder.replace(/^[\uff1a:\-\s]+/, ''); + if (!normalizedRemainder) { + return { title: prefix, inlineContent: '' }; + } + + const firstWhitespace = normalizedRemainder.search(/\s/); + if (firstWhitespace === -1) { + return { title: `${prefix} ${normalizedRemainder}`, inlineContent: '' }; + } + + const possibleTitle = normalizedRemainder.slice(0, firstWhitespace).trim(); + const inlineContent = normalizedRemainder.slice(firstWhitespace).trim(); + const looksLikeContent = /[\u3002\uff0c\uff01\uff1f,!.]/.test(possibleTitle) || possibleTitle.length > 20; + + if (!possibleTitle || !inlineContent || looksLikeContent) { + return { title: prefix, inlineContent: normalizedRemainder }; + } + + return { + title: `${prefix} ${possibleTitle}`, + inlineContent + }; + }; + + for (const line of lines) { + const trimmed = line.trim(); + const heading = splitEpisodeHeading(trimmed); + + if (heading) { + if (currentTitle || currentLines.length) { + pushCurrent(); + currentLines = []; + } + currentTitle = heading.title; + if (heading.inlineContent) { + currentLines.push(heading.inlineContent); + } + continue; + } + + currentLines.push(line); + } + + pushCurrent(); + + if (!episodes.length) { + return [{ + id: 'episode-0', + title: '\u5267\u672c\u5185\u5bb9', + content: normalized, + wordCount: countWords(normalized), + }]; + } + + return episodes.map((episode, index) => ({ + id: `episode-${index}`, + title: episode.title || `\u7b2c${index + 1}\u96c6`, + content: episode.content, + wordCount: countWords(episode.content), + })); +} + +function composeSourceTextFromEpisodes(episodes: ExtractedEpisode[]) { + return episodes + .map((episode) => [episode.title, episode.content].filter(Boolean).join('\n')) + .join('\n\n') + .trim(); +} + +function createConversionEpisodeResults(episodes: ExtractedEpisode[]): ConversionEpisodeResult[] { + return episodes.map((episode) => ({ + id: episode.id, + title: episode.title, + sourceContent: episode.content, + status: 'pending', + activeVersionId: '0', + selectedFinalVersionId: null, + versions: [ + { id: '0', label: 'A', content: '', status: 'pending' }, + { id: '1', label: 'B', content: '', status: 'pending' } + ] + })); +} + +interface CharacterPreviewField { + label: string; + value: string; +} + +interface CharacterPreviewCard { + id: string; + title: string; + fields: CharacterPreviewField[]; + rawContent: string; +} + +const CHARACTER_PREVIEW_FIELD_LABELS = new Set([ + '\u8eab\u4efd', + '\u6027\u683c', + '\u52a8\u673a', + '\u5173\u7cfb', + '\u5916\u8c8c', + '\u80cc\u666f', + '\u76ee\u6807', + '\u7279\u70b9', + '\u7ecf\u5386', + '\u59d3\u540d', + '\u89d2\u8272\u540d', + '\u4ecb\u7ecd' +]); + +function parseCharacterProfileCards(text: string): CharacterPreviewCard[] { + const normalized = text.replace(/\r\n/g, '\n').trim(); + if (!normalized) return []; + + const blocks = normalized.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean); + + return blocks.map((block, index) => { + const lines = block.split('\n').map((line) => line.trim()).filter(Boolean); + const potentialTitle = (lines[0] || '').replace(/[\uff1a:]$/, '').trim(); + const hasCustomTitle = Boolean(potentialTitle) && !CHARACTER_PREVIEW_FIELD_LABELS.has(potentialTitle) && potentialTitle.length <= 20; + let title = hasCustomTitle ? potentialTitle : `\u89d2\u8272 ${index + 1}`; + const contentLines = hasCustomTitle ? lines.slice(1) : lines; + + const fields: CharacterPreviewField[] = []; + const rawLines: string[] = []; + + for (let i = 0; i < contentLines.length; i += 1) { + const line = contentLines[i]; + const match = line.match(/^([^\uff1a:]{1,12})[\uff1a:]\s*(.*)$/); + if (match && CHARACTER_PREVIEW_FIELD_LABELS.has(match[1].trim())) { + const rawLabel = match[1].trim(); + const label = rawLabel === '\u52a8\u673a' ? '\u5916\u8c8c' : rawLabel; + let value = match[2].trim(); + + if (!value && i + 1 < contentLines.length) { + const nextLine = contentLines[i + 1].trim(); + const nextLineIsField = /^([^\uff1a:]{1,12})[\uff1a:]\s*(.*)$/.test(nextLine) + && CHARACTER_PREVIEW_FIELD_LABELS.has(nextLine.match(/^([^\uff1a:]{1,12})[\uff1a:]\s*(.*)$/)?.[1]?.trim() || ''); + + if (nextLine && !nextLineIsField) { + value = nextLine; + i += 1; + } + } + + const finalValue = value || '\u5f85\u8865\u5145'; + + if (label === '\u89d2\u8272\u540d' || label === '\u59d3\u540d') { + title = finalValue; + continue; + } + + fields.push({ label, value: finalValue }); + continue; + } + rawLines.push(line); + } + + if (rawLines.length > 0 && title.startsWith('\u89d2\u8272 ')) { + const possibleTitleFromRaw = rawLines[0].trim(); + if (possibleTitleFromRaw && possibleTitleFromRaw.length <= 20 && !/[\uff0c\u3002\uff01\uff1f]/.test(possibleTitleFromRaw)) { + title = possibleTitleFromRaw; + rawLines.shift(); + } + } + + return { + id: `character-${index}`, + title, + fields, + rawContent: rawLines.join('\n').trim() + }; + }).filter((card) => card.fields.length > 0 || card.rawContent || card.title); +} + + export default function App() { const [activeTab, setActiveTab] = useState<'conversion' | 'creation' | 'finalized'>('conversion'); const [previousTab, setPreviousTab] = useState<'conversion' | 'creation'>('conversion'); @@ -64,10 +314,12 @@ export default function App() { }, [finalizedScript]); // Common States (Three-level Filters) - const [scriptType, setScriptType] = useState('鐭墽'); + const [scriptType, setScriptType] = useState('\u77ed\u5267'); const [scriptFormat, setScriptFormat] = useState('鍏ㄩ儴'); const [selectedThemes, setSelectedThemes] = useState(['鎮枒']); - const [selectedNarratives, setSelectedNarratives] = useState(['椤哄彊']); + const [selectedNarratives, setSelectedNarratives] = useState(['\u987a\u53d9']); + const [audiencePreference, setAudiencePreference] = useState(() => (localStorage.getItem('scriptflow_audience_preference') as AudiencePreference) || '\u7537\u9891'); + const [wordRange, setWordRange] = useState(() => (localStorage.getItem('scriptflow_word_range') as WordRange) || '\u4e0d\u9650'); // Conversion Tool States const [sourceText, setSourceText] = useState(() => localStorage.getItem('scriptflow_conversion_source') || '鍦ㄤ竴涓亸杩滅殑灏忓北鏉戦噷锛屼綇鐫涓涓彨闃垮己鐨勫皬浼欏瓙銆傞樋寮轰粠灏忓氨鏈変竴涓ⅵ鎯筹紝閭e氨鏄幓澶у煄甯傞棷鑽°傛湁涓澶╋紝浠栧湪灞遍噷鏁戜簡涓涓彈浼ょ殑鑰佷汉锛岃佷汉涓磋蛋鍓嶉佺粰浠栦竴涓绉樼殑鏈ㄧ洅瀛愶紝鍛婅瘔浠栧彧鏈夊湪鏈缁濇湜鐨勬椂鍊欐墠鑳芥墦寮銆傞樋寮哄甫鐫鐩掑瓙鏉ュ埌浜嗙箒鍗庣殑閮藉競锛屽嵈鍙戠幇杩欓噷姣斾粬鎯宠薄鐨勮娈嬮叿寰楀...'); @@ -80,7 +332,29 @@ export default function App() { return []; }); const [selectedScriptId, setSelectedScriptId] = useState(() => localStorage.getItem('scriptflow_conversion_selected_id') || null); + const [conversionEpisodeResults, setConversionEpisodeResults] = useState(() => parseSavedConversionEpisodeResults(localStorage.getItem('scriptflow_conversion_episode_results')) as ConversionEpisodeResult[]); + const conversionEpisodeResultsRef = useRef([]); + const conversionAbortControllerRef = useRef(null); + const conversionAbortRequestedRef = useRef(false); + const [editingConversionVersionKey, setEditingConversionVersionKey] = useState(null); + const [authToken, setAuthToken] = useState(() => localStorage.getItem('scriptflow_auth_token') || ''); + const [currentUser, setCurrentUser] = useState(null); + const [showAuthModal, setShowAuthModal] = useState(false); + const [authMode, setAuthMode] = useState<'login' | 'register'>('login'); + const [authUsername, setAuthUsername] = useState(''); + const [authPassword, setAuthPassword] = useState(''); + const [authError, setAuthError] = useState(''); + const [isAuthSubmitting, setIsAuthSubmitting] = useState(false); + const [isRestoringSession, setIsRestoringSession] = useState(false); + const [isHydratingRemoteDraft, setIsHydratingRemoteDraft] = useState(false); + const [lastRemoteSaveAt, setLastRemoteSaveAt] = useState(''); const [copiedId, setCopiedId] = useState(null); + const [isExtractingSource, setIsExtractingSource] = useState(false); + const [isExtractingBackground, setIsExtractingBackground] = useState(false); + const [sourceExtractionError, setSourceExtractionError] = useState(''); + const [backgroundExtractionError, setBackgroundExtractionError] = useState(''); + const [sourceUploadName, setSourceUploadName] = useState(() => localStorage.getItem('scriptflow_conversion_source_upload_name') || ''); + const [extractedEpisodes, setExtractedEpisodes] = useState(() => parseEpisodesFromText(localStorage.getItem('scriptflow_conversion_source') || '')); const [isFinalizing, setIsFinalizing] = useState(false); const [isEditingFinalized, setIsEditingFinalized] = useState(false); const [isSettingsExpanded, setIsSettingsExpanded] = useState(true); @@ -91,10 +365,11 @@ export default function App() { if (saved) { try { return JSON.parse(saved); } catch (e) { console.error(e); } } - return [{ id: crypto.randomUUID(), story: '涓昏鍦ㄩ洦澶滄帴鍒颁竴涓绉樼數璇濓紝鍙戠幇瀵规柟绔熺劧鏄崄骞村墠澶辫釜鐨勭埗浜诧紝鐖朵翰璀﹀憡浠栫珛鍒荤寮鐜板湪鐨勬埧闂...', scripts: ['', '', ''], selectedScriptIndex: null }]; + return [{ id: crypto.randomUUID(), story: '\u4e3b\u89d2\u5728\u96e8\u591c\u63a5\u5230\u4e00\u4e2a\u795e\u79d8\u7535\u8bdd\uff0c\u53d1\u73b0\u5bf9\u65b9\u7adf\u7136\u662f\u5341\u5e74\u524d\u5931\u8e2a\u7684\u7236\u4eb2\uff0c\u7236\u4eb2\u8b66\u544a\u4ed6\u7acb\u523b\u79bb\u5f00\u73b0\u5728\u7684\u623f\u95f4...', scripts: ['', '', ''], selectedScriptIndex: null }]; }); const [currentPageIndex, setCurrentPageIndex] = useState(0); const lastPageIndexRef = useRef(0); + const conversionFileInputRef = useRef(null); const [isSyncing, setIsSyncing] = useState(false); const [isGeneratingAll, setIsGeneratingAll] = useState(false); const [finalizedFilter, setFinalizedFilter] = useState('鍏ㄩ儴'); @@ -104,23 +379,30 @@ export default function App() { const [conversionWorldview, setConversionWorldview] = useState(() => localStorage.getItem('scriptflow_conversion_worldview') || ''); const [conversionOutline, setConversionOutline] = useState(() => localStorage.getItem('scriptflow_conversion_outline') || ''); const [conversionCharacterProfiles, setConversionCharacterProfiles] = useState(() => localStorage.getItem('scriptflow_conversion_characterProfiles') || ''); + const [isConversionCharacterProfilesEditing, setIsConversionCharacterProfilesEditing] = useState(false); // Creation Settings States const [creationWorldview, setCreationWorldview] = useState(() => localStorage.getItem('scriptflow_creation_worldview') || ''); const [creationOutline, setCreationOutline] = useState(() => localStorage.getItem('scriptflow_creation_outline') || ''); const [creationCharacterProfiles, setCreationCharacterProfiles] = useState(() => localStorage.getItem('scriptflow_creation_characterProfiles') || ''); + const [isCreationCharacterProfilesEditing, setIsCreationCharacterProfilesEditing] = useState(false); // Persistence effect for Background Settings useEffect(() => { localStorage.setItem('scriptflow_conversion_worldview', conversionWorldview); localStorage.setItem('scriptflow_conversion_outline', conversionOutline); localStorage.setItem('scriptflow_conversion_characterProfiles', conversionCharacterProfiles); - + localStorage.setItem('scriptflow_creation_worldview', creationWorldview); localStorage.setItem('scriptflow_creation_outline', creationOutline); localStorage.setItem('scriptflow_creation_characterProfiles', creationCharacterProfiles); }, [conversionWorldview, conversionOutline, conversionCharacterProfiles, creationWorldview, creationOutline, creationCharacterProfiles]); + useEffect(() => { + localStorage.setItem('scriptflow_audience_preference', audiencePreference); + localStorage.setItem('scriptflow_word_range', wordRange); + }, [audiencePreference, wordRange]); + // Persistence effect for Content useEffect(() => { localStorage.setItem('scriptflow_conversion_source', sourceText); @@ -138,6 +420,144 @@ export default function App() { } }, [selectedScriptId]); + useEffect(() => { + conversionEpisodeResultsRef.current = conversionEpisodeResults; + }, [conversionEpisodeResults]); + + useEffect(() => { + localStorage.setItem('scriptflow_conversion_episode_results', JSON.stringify(sanitizeSavedConversionEpisodeResults(conversionEpisodeResults))); + }, [conversionEpisodeResults]); + + useEffect(() => { + if (sourceUploadName) { + localStorage.setItem('scriptflow_conversion_source_upload_name', sourceUploadName); + } else { + localStorage.removeItem('scriptflow_conversion_source_upload_name'); + } + }, [sourceUploadName]); + + useEffect(() => { + if (authToken) { + localStorage.setItem('scriptflow_auth_token', authToken); + } else { + localStorage.removeItem('scriptflow_auth_token'); + } + }, [authToken]); + + const buildRemoteDraft = () => ({ + version: 1, + savedAt: new Date().toISOString(), + data: { + scriptType, + scriptFormat, + selectedThemes, + selectedNarratives, + audiencePreference, + wordRange, + sourceText, + sourceUploadName, + extractedEpisodes, + conversionEpisodeResults, + conversionWorldview, + conversionOutline, + conversionCharacterProfiles, + finalizedScript, + }, + }); + + const hydrateRemoteDraft = (draft: Record) => { + setScriptType((draft.scriptType as ScriptType) || '\u77ed\u5267'); + setScriptFormat((draft.scriptFormat as ScriptFormat) || '鍏ㄩ儴'); + setSelectedThemes(Array.isArray(draft.selectedThemes) ? draft.selectedThemes as ThemeLabel[] : ['鎮枒']); + setSelectedNarratives(Array.isArray(draft.selectedNarratives) ? draft.selectedNarratives as NarrativeMethod[] : ['\u987a\u53d9']); + setAudiencePreference((draft.audiencePreference as AudiencePreference) || '鐢烽'); + setWordRange((draft.wordRange as WordRange) || '涓嶉檺'); + setSourceText(typeof draft.sourceText === 'string' ? draft.sourceText : ''); + setSourceUploadName(typeof draft.sourceUploadName === 'string' ? draft.sourceUploadName : ''); + setExtractedEpisodes(Array.isArray(draft.extractedEpisodes) ? draft.extractedEpisodes as ExtractedEpisode[] : []); + setConversionEpisodeResults(Array.isArray(draft.conversionEpisodeResults) + ? sanitizeSavedConversionEpisodeResults(draft.conversionEpisodeResults as ConversionEpisodeResult[]) + : []); + setConversionWorldview(typeof draft.conversionWorldview === 'string' ? draft.conversionWorldview : ''); + setConversionOutline(typeof draft.conversionOutline === 'string' ? draft.conversionOutline : ''); + setConversionCharacterProfiles(typeof draft.conversionCharacterProfiles === 'string' ? draft.conversionCharacterProfiles : ''); + setFinalizedScript(typeof draft.finalizedScript === 'string' ? draft.finalizedScript : ''); + setEditingConversionVersionKey(null); + }; + + useEffect(() => { + if (!authToken) return; + + let cancelled = false; + setIsRestoringSession(true); + + (async () => { + try { + const session = await restoreSession(authToken); + if (cancelled) return; + setCurrentUser(session.user); + + const remote = await loadCurrentDraft(authToken); + if (cancelled) return; + + if (remote.draft?.payload?.data && typeof remote.draft.payload.data === 'object') { + setIsHydratingRemoteDraft(true); + hydrateRemoteDraft(remote.draft.payload.data); + setLastRemoteSaveAt(remote.draft.updatedAt || remote.draft.payload.savedAt || ''); + window.setTimeout(() => setIsHydratingRemoteDraft(false), 0); + } + } catch (error) { + if (cancelled) return; + setAuthToken(''); + setCurrentUser(null); + setAuthError(error instanceof Error ? error.message : '\u6062\u590d\u4f1a\u8bdd\u5931\u8d25\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55'); + setShowAuthModal(true); + } finally { + if (!cancelled) { + setIsRestoringSession(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [authToken]); + + useEffect(() => { + if (!authToken || !currentUser || isHydratingRemoteDraft || isRestoringSession) return; + + const timeoutId = window.setTimeout(async () => { + try { + const remote = await saveCurrentDraft(authToken, buildRemoteDraft()); + setLastRemoteSaveAt(remote.draft.updatedAt); + } catch (error) { + console.error('Failed to save remote draft', error); + } + }, 800); + + return () => window.clearTimeout(timeoutId); + }, [ + authToken, + currentUser, + isHydratingRemoteDraft, + isRestoringSession, + scriptType, + scriptFormat, + selectedThemes, + selectedNarratives, + audiencePreference, + wordRange, + sourceText, + sourceUploadName, + extractedEpisodes, + conversionEpisodeResults, + conversionWorldview, + conversionOutline, + conversionCharacterProfiles, + finalizedScript, + ]); + useEffect(() => { localStorage.setItem('scriptflow_creation_pages', JSON.stringify(creationPages)); }, [creationPages]); @@ -146,82 +566,338 @@ export default function App() { const [aiModel, setAiModel] = useState<'gemini' | 'doubao'>('doubao'); const [aiApiKey, setAiApiKey] = useState(''); - const handleConvert = async () => { + const handleSourceFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + const previousSourceText = sourceText; + const previousEpisodes = extractedEpisodes; + const previousUploadName = sourceUploadName; + const previousWorldview = conversionWorldview; + const previousOutline = conversionOutline; + const previousCharacterProfiles = conversionCharacterProfiles; + + setSourceExtractionError(''); + setBackgroundExtractionError(''); + setIsConversionCharacterProfilesEditing(false); + setEditingConversionVersionKey(null); + setSourceUploadName(file.name); + setIsExtractingSource(true); + setIsExtractingBackground(true); + + try { + const parsedFile = await parseUploadedSourceFile(file); + setSourceText(''); + setExtractedEpisodes([]); + setScripts([]); + setSelectedScriptId(null); + setConversionEpisodeResults([]); + setConversionWorldview(''); + setConversionOutline(''); + setConversionCharacterProfiles(''); + + let episodeError: unknown = null; + let backgroundError: unknown = null; + + const episodePromise = extractEpisodesFromSource(parsedFile.text, (content) => { + setSourceText(content); + setExtractedEpisodes(parseEpisodesFromText(content)); + }, { model: 'doubao', apiKey: aiApiKey }) + .catch((error) => { + episodeError = error; + }) + .finally(() => { + setIsExtractingSource(false); + }); + + const backgroundPromise = extractPlotBackgroundFromSource(parsedFile.text, (fields) => { + setConversionWorldview(fields.worldview); + setConversionOutline(fields.outline); + setConversionCharacterProfiles(fields.characters); + }, { model: 'doubao', apiKey: aiApiKey }) + .catch((error) => { + backgroundError = error; + }) + .finally(() => { + setIsExtractingBackground(false); + }); + + await Promise.all([episodePromise, backgroundPromise]); + + if (episodeError) { + setSourceText(previousSourceText); + setExtractedEpisodes(previousEpisodes); + setSourceUploadName(previousUploadName); + setSourceExtractionError(episodeError instanceof Error ? episodeError.message : '\u5267\u96c6\u63d0\u53d6\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5'); + } + + if (backgroundError) { + setConversionWorldview(previousWorldview); + setConversionOutline(previousOutline); + setConversionCharacterProfiles(previousCharacterProfiles); + setBackgroundExtractionError(backgroundError instanceof Error ? backgroundError.message : '\u5267\u60c5\u80cc\u666f\u63d0\u53d6\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5'); + } + } catch (error) { + setSourceText(previousSourceText); + setExtractedEpisodes(previousEpisodes); + setSourceUploadName(previousUploadName); + setConversionWorldview(previousWorldview); + setConversionOutline(previousOutline); + setConversionCharacterProfiles(previousCharacterProfiles); + setSourceExtractionError(error instanceof Error ? error.message : '\u6587\u4ef6\u89e3\u6790\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5'); + setIsExtractingSource(false); + setIsExtractingBackground(false); + } + }; + + + const deleteEpisode = (episodeId: string) => { + setExtractedEpisodes((prev) => { + const nextEpisodes = prev.filter((episode) => episode.id !== episodeId); + setSourceText(composeSourceTextFromEpisodes(nextEpisodes)); + return nextEpisodes.map((episode, index) => ({ + ...episode, + id: `episode-${index}` + })); + }); + }; + + const clearExtractedEpisodes = () => { + setExtractedEpisodes([]); + setSourceText(''); + setSourceUploadName(''); + setSourceExtractionError(''); + setBackgroundExtractionError(''); + setIsConversionCharacterProfilesEditing(false); + setConversionWorldview(''); + setConversionOutline(''); + setConversionCharacterProfiles(''); + setEditingConversionVersionKey(null); + setScripts([]); + setSelectedScriptId(null); + setConversionEpisodeResults([]); + }; + + const handleConvert = async (restartAll = false) => { if (!sourceText.trim()) return; - // Validation: Require Story Outline and Worldview Settings if (!conversionOutline.trim() || !conversionWorldview.trim()) { - alert('璇峰厛鍦ㄢ滃墽鎯呰儗鏅濅腑濉啓鈥滄晠浜嬪ぇ绾测濆拰鈥滀笘鐣岃璁惧畾鈥濆悗鍐嶆墽琛岃浆鎹€'); + alert('\u8bf7\u5148\u8865\u5145\u5267\u60c5\u80cc\u666f\u4e2d\u7684\u4e16\u754c\u89c2\u8bbe\u5b9a\u548c\u6545\u4e8b\u5927\u7eb2\uff0c\u518d\u5f00\u59cb\u8f6c\u6362\u3002'); setShowSettingsModal(true); return; } + const sourceEpisodes = extractedEpisodes.length > 0 + ? extractedEpisodes + : [{ id: 'episode-0', title: '鍓ф湰鍐呭', content: sourceText, wordCount: countWords(sourceText) }]; + + const canResume = !restartAll && conversionEpisodeResults.length === sourceEpisodes.length + && conversionEpisodeResults.every((episode, index) => episode.id === sourceEpisodes[index]?.id); + + let workingResults = canResume + ? sourceEpisodes.map((episode, index) => { + const existingEpisode = conversionEpisodeResults[index]; + return { + ...existingEpisode, + id: episode.id, + title: episode.title, + sourceContent: episode.content, + status: existingEpisode.status === 'generating' ? 'paused' : existingEpisode.status, + versions: existingEpisode.versions.map((version) => ({ + ...version, + status: version.status === 'generating' ? 'paused' : version.status, + })), + }; + }) + : createConversionEpisodeResults(sourceEpisodes); + + const startIndex = getResumeStartIndex(workingResults); + if (startIndex === -1) { + alert('\u5f53\u524d\u6240\u6709\u5267\u96c6\u90fd\u5df2\u751f\u6210\u5b8c\u6210\u3002'); + return; + } + + conversionAbortRequestedRef.current = false; + setEditingConversionVersionKey(null); setIsConverting(true); - - // Initialize empty scripts for the 2 versions - const initialScripts = [ - { id: '0', content: '' }, - { id: '1', content: '' } - ]; - setScripts(initialScripts); - setSelectedScriptId('0'); - - // Combine themes and narratives for the AI prompt + setScripts([]); + setSelectedScriptId(null); + setConversionEpisodeResults(workingResults); + const combinedStyle = [...selectedThemes, ...selectedNarratives].join(' + '); - - await convertTextToScript( - sourceText, - scriptType, - scriptFormat, - combinedStyle, - (versionIndex, content) => { - setScripts(prev => { - const newScripts = [...prev]; - const idx = newScripts.findIndex(s => s.id === versionIndex.toString()); - if (idx !== -1) { - newScripts[idx] = { ...newScripts[idx], content }; + + for (let index = startIndex; index < sourceEpisodes.length; index += 1) { + if (conversionAbortRequestedRef.current) break; + + const episode = sourceEpisodes[index]; + const existingEpisode = workingResults[index]; + const targetVersionIds = getVersionsToGenerate(existingEpisode); + + if (targetVersionIds.length === 0) { + continue; + } + + const abortController = new AbortController(); + conversionAbortControllerRef.current = abortController; + + setConversionEpisodeResults((prev) => prev.map((item) => item.id === episode.id + ? { + ...item, + title: episode.title, + sourceContent: episode.content, + status: 'generating', + error: undefined, + versions: item.versions.map((version) => ( + targetVersionIds.includes(Number(version.id)) + ? { ...version, status: 'generating' } + : version + )), } - return newScripts; - }); - }, - { model: aiModel, apiKey: aiApiKey }, - { worldview: conversionWorldview, outline: conversionOutline, characters: conversionCharacterProfiles } - ); - + : item + )); + + const previousEpisode = index > 0 ? workingResults[index - 1] : null; + const previousSelectedVersion = previousEpisode?.versions.find((version) => version.id === (previousEpisode.selectedFinalVersionId ?? previousEpisode.activeVersionId)); + + try { + await convertTextToScript( + episode.content, + scriptType, + scriptFormat, + combinedStyle, + (versionIndex, content) => { + const nextVersionId = versionIndex.toString() as '0' | '1'; + setConversionEpisodeResults((prev) => prev.map((item) => { + if (item.id !== episode.id) return item; + return { + ...item, + activeVersionId: nextVersionId, + versions: item.versions.map((version) => version.id === nextVersionId + ? { ...version, content, status: 'generating' } + : version + ) + }; + })); + }, + { model: aiModel, apiKey: aiApiKey }, + { worldview: conversionWorldview, outline: conversionOutline, characters: conversionCharacterProfiles, audiencePreference, wordRange }, + { + signal: abortController.signal, + targetVersionIds, + seedContents: Object.fromEntries(existingEpisode.versions.map((version) => [Number(version.id), version.content])), + previousEpisodeContent: previousSelectedVersion?.content || '', + episodeTitle: episode.title, + episodeOutline: episode.content, + } + ); + + if (conversionAbortRequestedRef.current || abortController.signal.aborted) { + throw new DOMException('aborted', 'AbortError'); + } + + const latestEpisode = conversionEpisodeResultsRef.current.find((item) => item.id === episode.id) ?? existingEpisode; + const nextEpisode: ConversionEpisodeResult = { + ...latestEpisode, + status: latestEpisode.versions.every((version) => version.content.trim()) ? 'completed' : 'failed', + error: undefined, + versions: latestEpisode.versions.map((version) => ({ + ...version, + status: version.content.trim() ? 'completed' : 'failed', + })), + }; + + workingResults[index] = nextEpisode; + setConversionEpisodeResults((prev) => prev.map((item) => item.id === episode.id ? nextEpisode : item)); + } catch (error) { + if (isConversionAbortedError(error)) { + const latestEpisode = conversionEpisodeResultsRef.current.find((item) => item.id === episode.id) ?? existingEpisode; + const pausedEpisode: ConversionEpisodeResult = { + ...latestEpisode, + status: 'paused' as const, + error: undefined, + versions: latestEpisode.versions.map((version) => ( + targetVersionIds.includes(Number(version.id)) && version.status === 'generating' + ? { ...version, status: 'paused' as const } + : version + )), + }; + workingResults[index] = pausedEpisode; + setConversionEpisodeResults((prev) => prev.map((item) => item.id === episode.id ? pausedEpisode : item)); + break; + } + + const latestEpisode = conversionEpisodeResultsRef.current.find((item) => item.id === episode.id) ?? existingEpisode; + const message = error instanceof Error ? error.message : '杞崲澶辫触'; + const failedEpisode: ConversionEpisodeResult = { + ...latestEpisode, + status: 'failed' as const, + error: message, + versions: latestEpisode.versions.map((version) => ( + targetVersionIds.includes(Number(version.id)) + ? { ...version, status: version.content.trim() ? 'completed' as const : 'failed' as const } + : version + )), + }; + workingResults[index] = failedEpisode; + setConversionEpisodeResults((prev) => prev.map((item) => item.id === episode.id ? failedEpisode : item)); + } finally { + conversionAbortControllerRef.current = null; + } + } + + conversionAbortRequestedRef.current = false; setIsConverting(false); }; - const handleFinalize = () => { if (activeTab !== 'finalized') { setPreviousTab(activeTab as 'conversion' | 'creation'); } setIsFinalizing(true); - + let scriptToFinalize = ''; if (activeTab === 'conversion') { - const selected = scripts.find(s => s.id === selectedScriptId); - if (selected) scriptToFinalize = selected.content; + if (conversionEpisodeResults.length === 0) { + setIsFinalizing(false); + alert('\u5f53\u524d\u8fd8\u6ca1\u6709\u53ef\u5b9a\u7a3f\u7684\u5267\u672c\u3002'); + return; + } + + const pendingSelection = conversionEpisodeResults.find((episode) => !episode.selectedFinalVersionId); + if (pendingSelection) { + setIsFinalizing(false); + alert('\u8bf7\u5148\u4e3a\u6bcf\u4e00\u96c6\u9009\u62e9 A / B \u7248\u672c\u540e\u518d\u5b9a\u7a3f\u3002'); + return; + } + + scriptToFinalize = conversionEpisodeResults + .map((episode) => { + const selectedVersion = episode.versions.find((version) => version.id === episode.selectedFinalVersionId); + return `## ${episode.title}\n\n${selectedVersion?.content || ''}`; + }) + .join('\n\n---\n\n'); } else if (activeTab === 'creation') { const finalizedPages = creationPages.filter(p => p.selectedScriptIndex !== null); if (finalizedPages.length === 0) { - alert('鏆傛棤宸插畾绋跨殑鍓ф湰锛岃鍏堝湪鍒涗綔椤甸潰瀹氱ǹ銆'); + alert('\u8bf7\u5148\u5728\u521b\u4f5c\u6a21\u5f0f\u4e2d\u9009\u62e9\u81f3\u5c11\u4e00\u4e2a\u5b9a\u7a3f\u7248\u672c\u3002'); setIsFinalizing(false); return; } scriptToFinalize = finalizedPages.map((p) => { const script = p.scripts[p.selectedScriptIndex!]; const originalIndex = creationPages.indexOf(p); - return `## 绗 ${originalIndex + 1} 闆哱n\n${script}`; + return `## ?${originalIndex + 1}? + +${script}`; }).join('\n\n---\n\n'); } if (!scriptToFinalize) { setIsFinalizing(false); - alert('璇峰厛鐢熸垚鍓ф湰鍐呭鍚庡啀瀹氱ǹ'); + alert('\u5b9a\u7a3f\u5185\u5bb9\u4e3a\u7a7a\uff0c\u6682\u65f6\u65e0\u6cd5\u8fdb\u5165\u5b9a\u7a3f\u9875\u3002'); return; } - // Simulate archiving setTimeout(() => { setFinalizedScript(scriptToFinalize); setIsFinalizing(false); @@ -230,7 +906,41 @@ export default function App() { }, 800); }; - // Creation Tool Handlers + const handleAbortConversion = () => { + if (!isConverting) return; + conversionAbortRequestedRef.current = true; + conversionAbortControllerRef.current?.abort(); + }; + + const updateConversionEpisodeVersionContent = (episodeId: string, versionId: '0' | '1', content: string) => { + setConversionEpisodeResults((prev) => prev.map((episode) => episode.id === episodeId + ? { + ...episode, + activeVersionId: versionId, + versions: episode.versions.map((version) => version.id === versionId + ? { ...version, content } + : version + ) + } + : episode + )); + }; + + const setActiveConversionEpisodeVersion = (episodeId: string, versionId: '0' | '1') => { + setConversionEpisodeResults((prev) => prev.map((episode) => episode.id === episodeId + ? { ...episode, activeVersionId: versionId } + : episode + )); + }; + + const setConversionEpisodeFinalVersion = (episodeId: string, versionId: '0' | '1') => { + setConversionEpisodeResults((prev) => prev.map((episode) => episode.id === episodeId + ? { ...episode, selectedFinalVersionId: versionId, activeVersionId: versionId } + : episode + )); + }; + + // Creation Tool Handlers // Creation Tool Handlers const addPage = () => { const newPage: CreationPage = { id: crypto.randomUUID(), story: '', scripts: ['', '', ''], selectedScriptIndex: null }; setCreationPages([...creationPages, newPage]); @@ -274,7 +984,7 @@ export default function App() { // Validation: Require Story Outline and Worldview Settings if (!creationOutline.trim() || !creationWorldview.trim()) { - alert('璇峰厛鍦ㄢ滃墽鎯呰儗鏅濅腑濉啓鈥滄晠浜嬪ぇ绾测濆拰鈥滀笘鐣岃璁惧畾鈥濆悗鍐嶆墽琛岀敓鎴愩'); + alert('\u8bf7\u5148\u5728\u201c\u5267\u60c5\u80cc\u666f\u201d\u4e2d\u586b\u5199\u201c\u6545\u4e8b\u5927\u7eb2\u201d\u548c\u201c\u4e16\u754c\u89c2\u8bbe\u5b9a\u201d\u540e\u518d\u6267\u884c\u751f\u6210\u3002'); setShowSettingsModal(true); return; } @@ -305,7 +1015,7 @@ export default function App() { scriptFormat, selectedThemes, selectedNarratives, - { worldview: creationWorldview, outline: creationOutline, characters: creationCharacterProfiles }, + { worldview: creationWorldview, outline: creationOutline, characters: creationCharacterProfiles, audiencePreference, wordRange }, previousScript, (versionIndex, content) => { setCreationPages(prevPages => { @@ -329,7 +1039,7 @@ export default function App() { const handleGenerateAll = async () => { // Validation: Require Story Outline and Worldview Settings if (!creationOutline.trim() || !creationWorldview.trim()) { - alert('璇峰厛鍦ㄢ滃墽鎯呰儗鏅濅腑濉啓鈥滄晠浜嬪ぇ绾测濆拰鈥滀笘鐣岃璁惧畾鈥濆悗鍐嶆墽琛屾壒閲忕敓鎴愩'); + alert('\u8bf7\u5148\u5728\u201c\u5267\u60c5\u80cc\u666f\u201d\u4e2d\u586b\u5199\u201c\u6545\u4e8b\u5927\u7eb2\u201d\u548c\u201c\u4e16\u754c\u89c2\u8bbe\u5b9a\u201d\u540e\u518d\u6267\u884c\u6279\u91cf\u751f\u6210\u3002'); setShowSettingsModal(true); return; } @@ -342,7 +1052,7 @@ export default function App() { selectedThemes, selectedNarratives, { model: aiModel, apiKey: aiApiKey }, - { worldview: creationWorldview, outline: creationOutline, characters: creationCharacterProfiles } + { worldview: creationWorldview, outline: creationOutline, characters: creationCharacterProfiles, audiencePreference, wordRange } ); const newPages = creationPages.map((p, i) => ({ ...p, scripts: results[i] })); setCreationPages(newPages); @@ -351,8 +1061,10 @@ export default function App() { const exportAllScripts = () => { const fullScript = creationPages.map((p, i) => { - const script = p.selectedScriptIndex !== null ? p.scripts[p.selectedScriptIndex] : (p.scripts[0] || '鏈敓鎴'); - return `## 绗 ${i + 1} 闆哱n\n${script}`; + const script = p.selectedScriptIndex !== null ? p.scripts[p.selectedScriptIndex] : (p.scripts[0] || '\u672a\u751f\u6210'); + return `## ?${i + 1} ? + +${script}`; }).join('\n\n---\n\n'); const blob = new Blob([fullScript], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); @@ -374,7 +1086,7 @@ export default function App() { if (!trimmed) return; // Episode Detection - if (trimmed.startsWith('## 绗') && trimmed.includes('闆')) { + if (trimmed.startsWith('## ?') && trimmed.includes('?')) { items.push({ type: 'episode', text: trimmed.replace(/^[#*]+/, '').trim(), @@ -393,11 +1105,28 @@ export default function App() { return items; }, [finalizedScript]); + const activeCharacterProfiles = activeTab === 'conversion' ? conversionCharacterProfiles : creationCharacterProfiles; + const isCharacterProfilesEditing = activeTab === 'conversion' ? isConversionCharacterProfilesEditing : isCreationCharacterProfilesEditing; + const characterProfileCards = parseCharacterProfileCards(activeCharacterProfiles); + const conversionCompletedCount = conversionEpisodeResults.filter((episode) => episode.status === 'completed').length; + const currentGeneratingEpisode = conversionEpisodeResults.find((episode) => episode.status === 'generating'); + const pausedConversionEpisode = conversionEpisodeResults.find((episode) => episode.status === 'paused'); + const resumableConversionIndex = getResumeStartIndex(conversionEpisodeResults); + const hasResumableConversion = conversionEpisodeResults.length > 0 && resumableConversionIndex !== -1; + + const setCharacterProfilesEditing = (value: boolean) => { + if (activeTab === 'conversion') { + setIsConversionCharacterProfilesEditing(value); + return; + } + setIsCreationCharacterProfilesEditing(value); + }; + const handleShowFinalizedCreation = () => { const finalizedPages = creationPages.filter(p => p.selectedScriptIndex !== null); if (finalizedPages.length === 0) { - alert('鏆傛棤宸插畾绋跨殑鍓ф湰锛岃鍏堝湪鍒涗綔椤甸潰瀹氱ǹ銆'); + alert('\u6682\u65e0\u5df2\u5b9a\u7a3f\u7684\u5267\u672c\uff0c\u8bf7\u5148\u5728\u521b\u4f5c\u9875\u9762\u5b9a\u7a3f\u3002'); return; } @@ -405,7 +1134,9 @@ export default function App() { const fullScript = finalizedPages.map((p) => { const script = p.scripts[p.selectedScriptIndex!]; const originalIndex = creationPages.indexOf(p); - return `## 绗 ${originalIndex + 1} 闆哱n\n${script}`; + return `## ?${originalIndex + 1} ? + +${script}`; }).join('\n\n---\n\n'); setFinalizedScript(fullScript); setActiveTab('finalized'); @@ -425,6 +1156,60 @@ export default function App() { setTimeout(() => setCopiedId(null), 2000); }; + const handleAuthSubmit = async () => { + if (!authUsername.trim() || !authPassword.trim()) { + setAuthError('\u8bf7\u8f93\u5165\u8d26\u53f7\u548c\u5bc6\u7801'); + return; + } + + setIsAuthSubmitting(true); + setAuthError(''); + try { + const result = authMode === 'login' + ? await login(authUsername.trim(), authPassword) + : await register(authUsername.trim(), authPassword); + setAuthToken(result.token); + setCurrentUser(result.user); + setShowAuthModal(false); + setAuthPassword(''); + } catch (error) { + setAuthError(error instanceof Error ? error.message : '\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5'); + } finally { + setIsAuthSubmitting(false); + } + }; + + const handleLogout = async () => { + if (authToken) { + try { + await logout(authToken); + } catch (error) { + console.error(error); + } + } + setAuthToken(''); + setCurrentUser(null); + setLastRemoteSaveAt(''); + }; + + const handleRegenerateAll = () => { + setConversionEpisodeResults([]); + setEditingConversionVersionKey(null); + void handleConvert(true); + }; + + const openAuthModal = (mode: 'login' | 'register' = 'login') => { + setAuthMode(mode); + setAuthError(''); + setShowAuthModal(true); + }; + + const remoteSaveStatusLabel = currentUser + ? (lastRemoteSaveAt + ? `????? ${new Date(lastRemoteSaveAt).toLocaleString()}` + : '????????????') + : '????????????'; + return (
{/* Sidebar */} @@ -462,6 +1247,65 @@ export default function App() {
+ {showAuthModal && ( +
+
+
+
+

{'\u4e91\u7aef\u540c\u6b65'}

+

{authMode === 'login' ? '\u767b\u5f55\u8d26\u53f7' : '\u6ce8\u518c\u8d26\u53f7'}

+

{'\u767b\u5f55\u540e\u4f1a\u81ea\u52a8\u540c\u6b65\u89e3\u6790\u7ed3\u679c\u3001\u751f\u6210\u8fdb\u5ea6\u548c\u5df2\u7f16\u8f91\u5267\u672c\u5185\u5bb9\u3002'}

+
+ +
+ +
+ {(['login', 'register'] as const).map((mode) => ( + + ))} +
+ +
+ setAuthUsername(event.target.value)} + placeholder={'\u8bf7\u8f93\u5165\u8d26\u53f7'} + className="w-full rounded-2xl border border-[#D2D2D7]/50 bg-white px-4 py-3 text-sm outline-none focus:border-[#0071E3]/40 focus:ring-2 focus:ring-[#0071E3]/10" + /> + setAuthPassword(event.target.value)} + placeholder={'\u8bf7\u8f93\u5165\u5bc6\u7801'} + className="w-full rounded-2xl border border-[#D2D2D7]/50 bg-white px-4 py-3 text-sm outline-none focus:border-[#0071E3]/40 focus:ring-2 focus:ring-[#0071E3]/10" + /> + {authError &&

{authError}

} +
+ + +
+
+ )} + {activeTab === 'finalized' ? ( /* Finalized View - Professional Script Layout */
@@ -476,8 +1320,8 @@ export default function App() {
-

瀹氱ǹ鍓ф湰锛歿scriptType}涓撲笟鐗

-

Production Draft 鈥 {new Date().toLocaleDateString()}

+

{`\u5b9a\u7a3f\u5267\u672c\uff1a${scriptType}\u4e13\u4e1a\u7248`}

+

{`Production Draft ? ${new Date().toLocaleDateString()}`}

@@ -528,8 +1372,8 @@ export default function App() { if (!trimmedLine) return false; const isScene = trimmedLine.includes('SCENE') || trimmedLine.includes('鍦烘櫙') || trimmedLine.startsWith('#'); - const isChar = !isScene && (trimmedLine.includes('锛') || trimmedLine.includes(':')) && trimmedLine.length <= 20; - const isDialogue = !isScene && (trimmedLine.startsWith('鈥') || trimmedLine.startsWith('"')); + const isChar = !isScene && (trimmedLine.includes('?') || trimmedLine.includes(':')) && trimmedLine.length <= 20; + const isDialogue = !isScene && (trimmedLine.startsWith('?') || trimmedLine.startsWith('\"')); const cameraKeywords = ['闀滃ご', '鐗瑰啓', '杩滄櫙', '杩戞櫙', '涓櫙', '鍏ㄦ櫙', '鎺ㄩ暅', '鎷夐暅', '鍒囨崲']; const isCamera = !isScene && cameraKeywords.some(k => trimmedLine.includes(k)); const isAction = !isScene && !isChar && !isDialogue && !isCamera; @@ -732,8 +1576,8 @@ export default function App() { h2: ({children}) => { const text = React.Children.toArray(children).map(c => c?.toString() || '').join(''); let id = ''; - if (text.startsWith('绗') && text.includes('闆')) { - const allEpisodes = finalizedScript.split('\n').filter(l => l.trim().startsWith('## 绗') && l.trim().includes('闆')); + if (text.startsWith('?') && text.includes('?')) { + const allEpisodes = finalizedScript.split('\n').filter(l => l.trim().startsWith('## ?') && l.trim().includes('?')); const idx = allEpisodes.findIndex(e => e.includes(text)); id = `episode-${idx}`; } else if (text.startsWith('鍦烘櫙') || text.includes('SCENE')) { @@ -761,11 +1605,11 @@ export default function App() { // 2. Character // Rule: Contains colon, len <= 20, not a scene - const isChar = !isScene && (trimmedLine.includes('锛') || trimmedLine.includes(':')) && trimmedLine.length <= 20; + const isChar = !isScene && (trimmedLine.includes('?') || trimmedLine.includes(':')) && trimmedLine.length <= 20; // 3. Dialogue // Rule: Starts with quote - const isDialogue = !isScene && (trimmedLine.startsWith('鈥') || trimmedLine.startsWith('"')); + const isDialogue = !isScene && (trimmedLine.startsWith('?') || trimmedLine.startsWith('\"')); // 4. Camera // Rule: Keywords @@ -797,21 +1641,139 @@ export default function App() {
{/* Left Panel: Source Input */}
-
-
- - 婧愭枃鏈緭鍏 +
+
+
+ + {`婧愭枃鍓ф湰绠$悊`} +
+
+ {(isExtractingSource || isExtractingBackground || extractedEpisodes.length > 0) && ( + + {countWords(sourceText)}{`瀛梎} + + )} + + {extractedEpisodes.length > 0 && !isExtractingSource && !isExtractingBackground && ( + + )} + +
- {sourceText.length} 瀛 +
+
+ {`鏀寔 Word / TXT / PDF / MD锛岃В鏋愬悗浠ュ墽闆嗗崱鐗囧舰寮忕鐞哷} + {`鏍囬灞曠ず鏍煎紡锛氱鍑犻泦锛氶泦鍚嶇О锛岄泦鍚嶇О浼氭牴鎹枃浠跺唴瀹硅嚜鍔ㄨ瘑鍒紝鍙负绌恒俙} +
+ {sourceUploadName && ( + {sourceUploadName} + )} +
+ {sourceExtractionError && ( +

{sourceExtractionError}

+ )} + {backgroundExtractionError && ( +

{backgroundExtractionError}

+ )}
- -
-