diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index a18bc5dd..274c4bb2 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -76,3 +76,26 @@ jobs: run: | cd src/frontend npm audit --audit-level=high + + frontend-e2e-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: src/frontend/package-lock.json + - name: Install dependencies + run: | + cd src/frontend + npm ci --legacy-peer-deps + - name: Install Playwright browsers + run: | + cd src/frontend + npx playwright install --with-deps chromium + - name: Run E2E cursor/caret regression tests + run: | + cd src/frontend + npm run test:e2e diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da4681b3..511a3fb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,5 +28,5 @@ repos: language: system # Run eslint using the local installation in frontend/node_modules # This ensures it uses the correct version and plugins - entry: src/frontend/node_modules/.bin/eslint --config src/frontend/eslint.config.cjs - files: ^src/frontend/.*\.(js|jsx|ts|tsx)$ + entry: src/frontend/node_modules/.bin/eslint --config src/frontend/eslint.config.cjs --ext .ts,.tsx + files: ^src/frontend/.*\.(ts|tsx)$ diff --git a/.vscode/launch.json b/.vscode/launch.json index b654034a..d322e0b1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,6 +14,19 @@ "AUGQ_LLM_DUMP_LEVEL": "normal" } }, + { + "name": "Backend (Dev API 28000) Verbose", + "type": "debugpy", + "request": "launch", + "module": "augmentedquill.main", + "args": ["--host", "127.0.0.1", "--port", "28000", "--reload", "--llm-dump"], + "console": "integratedTerminal", + "justMyCode": true, + "preLaunchTask": "backend: prepare", + "env": { + "AUGQ_LLM_DUMP_LEVEL": "debug" + } + }, { "name": "Frontend (Vite 28001)", "type": "node-terminal", @@ -31,6 +44,11 @@ "name": "Full Stack Dev (Backend + Frontend)", "configurations": ["Backend (Dev API 28000)", "Frontend (Vite 28001)"], "stopAll": true + }, + { + "name": "Full Stack Dev (Backend + Frontend) Verbose", + "configurations": ["Backend (Dev API 28000) Verbose", "Frontend (Vite 28001)"], + "stopAll": true } ] } diff --git a/AGENTS.md b/AGENTS.md index 8af7ab6f..1d8708d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,10 @@ npm run check:generated-types ## 7. Style And Conventions +### Test driven development (TDD) + +Implement test cases to cover the user specification first and then implement the solution. Finish with implementing test cases that cover every valid input as well as those that have bad input to test correct error handling, so that any regression is easily detected. + ### Required file headers - Every new/modified `.py`, `.ts`, `.tsx`, `.js` file must include: @@ -191,10 +195,11 @@ python tools/check_copyright.py . ## 9.5 Project File Schema Evolution Rules -- Changes to project/story files must either be backward compatible or explicitly bump the config/schema version. +- Changes to project/story files must either be intrinsic backward compatible or explicitly bump the config/schema version. - Any version bump must include an automatic conversion function from the prior version. - Conversions must be chainable step-by-step (for example v2 -> v3 -> v4) so older versions can be upgraded safely through intermediate migrations. - Never introduce a breaking project-file format change without a tested migration path. +- Never introduce code that handles older versions of the files, always relay on the files having the current version and the version conversion scripts are used to make the files compatible. Do not use in project code for backward compatability. ## 10. Security And Operational Guardrails @@ -205,7 +210,7 @@ python tools/check_copyright.py . ## 11. Agent Workflow Heuristics - Read only what is needed for the current task. -- Prefer surgical edits over broad rewrites. +- Prefer surgical edits over broad rewrites. But also do not hesitate a rewrite when that increases maintainability and prevents duplication. - After edits, run relevant checks and report concrete outcomes. - If failures are unrelated to your change, report them clearly instead of silently changing unrelated code. - Preserve existing style and naming in touched files. diff --git a/docs/user_manual/01_getting_started.md b/docs/user_manual/01_getting_started.md index a1b26304..dce220be 100644 --- a/docs/user_manual/01_getting_started.md +++ b/docs/user_manual/01_getting_started.md @@ -92,10 +92,10 @@ On medium screens the less common buttons collapse into a **Format** dropdown (t | Control | Description | | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Images** (Image icon image icon) | Opens the **Project Images** dialog to manage all visual assets for this project. (See [Project Images](06_project_images.md).) | +| **Images** (Image icon image icon) | Opens the **Project Images** dialog to manage all visual assets for this project. (See [Project Images](09_project_images.md).) | | **Settings** (Settings icon) | Opens the Settings dialog (same as clicking the logo). | -| **Appearance** (Type icon Type icon) | Opens the Appearance popup to adjust the visual theme, font size, and line width. (See [Appearance and Display](08_appearance_and_display.md).) | -| **Debug Logs** (bug icon) | Opens the LLM Debug Logs overlay — a developer-focused view of all AI requests and responses. (See [Appearance and Display](08_appearance_and_display.md#debug-logs).) | +| **Appearance** (Type icon Type icon) | Opens the Appearance popup to adjust the visual theme, font size, and line width. (See [Appearance and Display](10_appearance_and_display.md).) | +| **Debug Logs** (bug icon) | Opens the LLM Debug Logs overlay — a developer-focused view of all AI requests and responses. (See [Appearance and Display](10_appearance_and_display.md#debug-logs).) | | **Hide / AI** (panel icon) | Toggles the right Chat Assistant panel open or closed. The current label flips between **Hide** (chevron-right) and **AI** (chevron-left) depending on the panel state. | --- diff --git a/docs/user_manual/02_projects_and_settings.md b/docs/user_manual/02_projects_and_settings.md index bff17ad4..8471e50a 100644 --- a/docs/user_manual/02_projects_and_settings.md +++ b/docs/user_manual/02_projects_and_settings.md @@ -27,11 +27,11 @@ The Projects tab lists every project stored on this machine and lets you create, Three buttons sit above the project list: -| Button | Icon | Action | -| --------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Refresh** | Refresh icon ↺ | Rescans the projects folder from disk — useful if you have added or removed project folders manually. | -| **Import** | ↑ Upload | Opens a file picker that accepts `.zip` files. AugmentedQuill will unpack the archive and add the project to your list. This is the matching action to the Export button on each project row. | -| **New Project** | Plus icon | Opens the **Create Project** dialog (see below). | +| Button | Icon | Action | +| --------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Refresh** | Refresh icon | Rescans the projects folder from disk — useful if you have added or removed project folders manually. | +| **Import** | ↑ Upload | Opens a file picker that accepts `.zip` files. AugmentedQuill will unpack the archive and add the project to your list. This is the matching action to the Export button on each project row. | +| **New Project** | Plus icon | Opens the **Create Project** dialog (see below). | ### Project Cards @@ -305,10 +305,6 @@ Click **Save & Close** at the bottom of the Settings dialog to write your machin --- -Next up: Explore [The Writing Interface](03_writing_interface.md). - ---- - ## Multiple Languages (GUI vs Project) AugmentedQuill supports distinct languages for its interface (GUI) and your story content. @@ -325,3 +321,7 @@ The story language dictates the spellchecking and grammar tools used by your cho 1. When creating a **New Project**, select the language in the project dialog. 2. OR, open the **Project Settings** (by clicking your story title in the corner or typing in the Project tab) and change the language dropdown. 3. Chat fields, Editors, and Images will automatically adapt their spellchecker to the project's saved language. + +--- + +Next up: Explore [The Writing Interface](03_writing_interface.md). diff --git a/docs/user_manual/03_writing_interface.md b/docs/user_manual/03_writing_interface.md index e0f207a4..b87bd98b 100644 --- a/docs/user_manual/03_writing_interface.md +++ b/docs/user_manual/03_writing_interface.md @@ -28,7 +28,7 @@ You can switch modes at any time without losing data; the underlying markdown fi ### Dropping Images Into the Editor -Drag an image file (PNG, JPEG, GIF, WEBP) and drop it onto the editor paper area. A highlighted drop zone appears while the image is being dragged over. On release, the image uploads to the server and an inline markdown image tag (`![title](url)`) is inserted at the current cursor position. You can manage all project images from the **Project Images** dialog (see [Project Images](06_project_images.md)). +Drag an image file (PNG, JPEG, GIF, WEBP) and drop it onto the editor paper area. A highlighted drop zone appears while the image is being dragged over. On release, the image uploads to the server and an inline markdown image tag (`![title](url)`) is inserted at the current cursor position. You can manage all project images from the **Project Images** dialog (see [Project Images](09_project_images.md)). --- @@ -225,7 +225,7 @@ These controls update the planning layer around a chapter rather than the prose - The suggestion pane always shows the last set of continuations, so you can keep requesting more or hit `Dismiss` once you are satisfied. - Even when AI actions are running, you can keep typing because the UI disables only the relevant button and leaves the rest of the editor responsive. - Switch between Raw, MD, and Visual modes freely — the underlying file is the same markdown source regardless of which view you use. -- Press **Ctrl+F** (or **Cmd+F** on macOS) at any time — or click the **Search icon Search** icon in the top-right header — to open the project-wide **Search and Replace** dialog. See [Search and Replace](12_search_and_replace.md) for the full guide. +- Press **Ctrl+F** (or **Cmd+F** on macOS) at any time — or click the **Search icon Search** icon in the top-right header — to open the project-wide **Search and Replace** dialog. See [Search and Replace](08_search_and_replace.md) for the full guide. --- diff --git a/docs/user_manual/04_chapters_and_books.md b/docs/user_manual/04_chapters_and_books.md index 51bbe736..212720e3 100644 --- a/docs/user_manual/04_chapters_and_books.md +++ b/docs/user_manual/04_chapters_and_books.md @@ -57,7 +57,7 @@ The Metadata Editor is a full-screen or sidebar panel that opens whenever you cl - The text area holds the public description that appears in the sidebar card and is sent to every AI call that involves this chapter or story. - When the summary is empty, an **AI Write** button (Wand icon) asks the Fuchsia swatch **EDITING** model to draft a summary from the chapter content. -- Once a summary exists, the button becomes **AI Update** (Refresh icon) which updates the existing text, and an adjacent **AI Rewrite** (Pen icon) button regenerates it from scratch — useful when the story direction has changed. +- Once a summary exists, the button becomes **AI Update** (Refresh icon) which updates the existing text, and an adjacent **AI Rewrite** (Pen icon) button regenerates it from scratch — useful when the story direction has changed. **Notes tab:** diff --git a/docs/user_manual/05_sourcebook.md b/docs/user_manual/05_sourcebook.md index 574b770e..7233f864 100644 --- a/docs/user_manual/05_sourcebook.md +++ b/docs/user_manual/05_sourcebook.md @@ -93,7 +93,7 @@ The Image Picker is a sub-modal that appears when you click **Manage Images** in - A count below the grid shows how many images are currently selected. - Click **Done** (✓) to close the picker and apply the selection to the entry. -Images shown here are managed in the [Project Images](06_project_images.md) dialog. You can upload new images there and they will appear in this picker immediately. +Images shown here are managed in the [Project Images](09_project_images.md) dialog. You can upload new images there and they will appear in this picker immediately. --- @@ -118,4 +118,4 @@ Mouse-over tooltips on the **Auto** control, spinner, and disabled checkboxes ex --- -Next up: Manage your project's visual assets in [Project Images](06_project_images.md). +Next up: Apply everything you've learned in the [Tutorial: Writing Your First Story](06_tutorial_first_story.md). diff --git a/docs/user_manual/06_project_images.md b/docs/user_manual/06_project_images.md index 0b1ce0f9..6dc34bb8 100644 --- a/docs/user_manual/06_project_images.md +++ b/docs/user_manual/06_project_images.md @@ -94,4 +94,4 @@ Paste the copied prompt into your preferred image generation tool (Stable Diffus --- -Next up: Discover your AI writing partner in [The AI Chat Assistant](07_ai_chat_assistant.md). +Next up: Customize the look and feel in [Appearance and Display](08_appearance_and_display.md). diff --git a/docs/user_manual/06_tutorial_first_story.md b/docs/user_manual/06_tutorial_first_story.md new file mode 100644 index 00000000..ed314074 --- /dev/null +++ b/docs/user_manual/06_tutorial_first_story.md @@ -0,0 +1,145 @@ +# Tutorial: Writing Your First Story + +Welcome to the tutorial! In this guide, we won't just look at which buttons to click; we'll walk through the actual process of crafting a compelling story using AugmentedQuill. We will also show you how to perform each step manually or by using the AI Chat Assistant. + +Before you begin drafting prose, keep the default rhythm in mind: notes first, then title and style tags, then the story summary, then Sourcebook work, then chapter notes and conflicts, and only then full prose generation. You can always circle back and refine earlier metadata when the story evolves. + +## Step 1: The Spark of an Idea + +Every story starts with a concept. Before you write a single word of prose, you need to know what your story is about. + +**Manually:** + +1. Open AugmentedQuill and click the Settings icon **Settings** icon in the top navigation bar. +2. In the **Projects** tab, click **New Project**. Name it "The Lost Heirloom". +3. Close the Settings dialog. In the left sidebar, find the **Story Metadata** section. + +**Using Chat:** + +1. Open the **[AI Chat Assistant](07_ai_chat_assistant.md)** in the right sidebar. +2. Type: _"Create a new project called 'The Lost Heirloom'."_ + +### Writing Advice: The Core Conflict + +A story without conflict is just a sequence of events. Conflict is the engine that drives the narrative forward. It can be internal (a character battling their own fears) or external (a character battling a villain or nature). + +**Manually:** +In the **Story Metadata** section, write a brief summary that highlights the conflict. +_Example:_ "Elara, a cautious librarian, discovers a map to a lost family heirloom. She must overcome her fear of the outside world and race against a ruthless treasure hunter to find it before it falls into the wrong hands." + +**Using Chat:** +Type: _"Update the story summary to: Elara, a cautious librarian, discovers a map to a lost family heirloom. She must overcome her fear of the outside world and race against a ruthless treasure hunter to find it before it falls into the wrong hands."_ + +## Step 2: Populating the World + +Before writing, it helps to know who is in the story and where it takes place. + +**Manually:** + +1. Navigate to the **[Sourcebook](05_sourcebook.md)** in the left sidebar. +2. Create a **Character** entry for your protagonist (e.g., Elara). Fill in her physical description, but more importantly, her _motivations_ and _flaws_. +3. Create a **Character** entry for your antagonist. +4. Create a **Location** entry for the starting point of your story (e.g., The Grand Library). + +**Using Chat:** + +1. Type: _"Create a character profile in the Sourcebook for Elara. She is a cautious librarian who is secretly brave but afraid of the outside world."_ +2. Type: _"Create a character profile for the antagonist, a ruthless treasure hunter named Silas."_ +3. Type: _"Create a location entry for The Grand Library, an ancient building filled with dusty tomes and hidden secrets."_ + +![The Sourcebook showing the newly created entries for Elara and the Library](screenshots/09_sourcebook.png) + +## Step 3: Outlining with the AI + +If you aren't sure how to get from the beginning to the end, the AI can help. + +1. Open the **[AI Chat Assistant](07_ai_chat_assistant.md)**. +2. Type: _"I have a story about a librarian named Elara looking for a lost heirloom. Can you help me outline the first three chapters? Keep in mind she is very cautious."_ +3. The AI will generate an outline. You can ask it to tweak things: _"Make Chapter 2 more action-packed."_ + +## Step 4: Writing the First Chapter + +Now it's time to write. + +**Manually:** + +1. Go to the **[Chapters](04_chapters_and_books.md)** panel in the left sidebar and click **Add Chapter**. Name it "Chapter 1". +2. Select the chapter to open it in the Editor. + +**Using Chat:** + +1. Type: _"Create a new chapter called 'Chapter 1'."_ + +### Writing Advice: Start with Action or Intrigue + +Don't start with a long description of the weather or the character's morning routine. Start at a moment of change. + +_Example:_ Instead of "Elara woke up and went to work," start with "The ancient book practically burned Elara's fingers as she pulled it from the restricted section." + +3. Start writing your scene in the Editor. + +## Step 5: Using AI to Enhance Your Prose + +As you write, you might hit a wall. Let's say you need to describe the antagonist entering the library, but you can't find the right words. + +**Using the Chapter AI:** + +1. Click the **Rewrite** (File Edit icon) button in the header to have the AI regenerate the entire chapter body with better prose. This is a great "reset" option early in the writing process. +2. To extend the chapter with a new scene, click **Extend** (Wand icon) and the AI will continue writing from where you left off. +3. For smaller suggestions at the paragraph level, click **Suggest next paragraph** at the bottom of the editor. Use the mode selector on the right: choose **Guided** for stronger context-guided continuation, **Instructed** for explicit next-paragraph continuation, or **Pure** for continuation from only the current chapter text. + +The suggestion cards appear — click any card to insert that text, or press `Escape` to dismiss. + +**Using Chat:** + +1. Open the **[AI Chat Assistant](07_ai_chat_assistant.md)** in the right sidebar. +2. Type: _"I just wrote 'The bad guy walked in and looked mean.' Can you rewrite this to be more intimidating and descriptive? Remember he is a ruthless treasure hunter named Silas."_ +3. Review the result. Depending on the task, CHAT may either return revised wording for review or use the EDITING workflow to update the chapter directly. + +`[SCREENSHOT: The suggestion footer open with continuation cards based on the current chapter context]` + +## Step 6: Adding Visual References + +Visual references help both you and the AI stay consistent with how characters and places look. + +1. Click the Image icon **Images** icon in the header to open the [Project Images](09_project_images.md) dialog. +2. Click **Create Placeholder** and name it "Elara — character portrait". +3. Fill in the **Description** field with a text description: "A cautious young librarian with dark eyes, ink-stained fingers, and reading glasses perched on her nose." +4. Click **Create prompt** (Sparkles icon) to have the AI generate an image-generation prompt you can use in an external art tool. +5. Once you have the actual image, click **Replace** on the placeholder card to upload it. +6. Open the Elara entry in the Sourcebook and click **Manage Images** to link the portrait to her character profile. + +`[SCREENSHOT: Project Images dialog showing the Elara placeholder card with the generated prompt popup open]` + +## Step 7: Pacing and Review + +Once you finish the chapter, it's time to review. + +### Writing Advice: Pacing + +Pacing is how fast or slow the story feels. Action scenes should have shorter sentences and paragraphs to feel fast. Emotional or descriptive scenes can be longer and more flowing. + +1. Open the **AI Chat Assistant**. +2. Type: _"Please review Chapter 1. How is the pacing? Is the conflict clear?"_ +3. The AI will analyze your text and provide constructive feedback. + +## Step 8: Set Up Your Writing Environment + +Before your next long writing session, take a moment to tune the appearance to your preferences. + +1. Click the Type icon **Appearance** button in the header. +2. Try **Mixed** or **Dark** design mode if you prefer less eye strain. +3. Adjust **Font Size** and **Line Width** until the page feels comfortable for a long session. +4. See [Appearance and Display](10_appearance_and_display.md) for all available settings. + +--- + +## Conclusion + +Congratulations! You have set up a project, built your world in the Sourcebook, outlined with the AI, written your first chapter, added visual references to your project images, and reviewed the pacing of your prose. + +Remember, AugmentedQuill is a tool to enhance _your_ creativity. The story is always yours. Keep writing, keep experimenting, and let your imagination run wild! + +--- + +Next up: Learn how to use your AI writing partner in [The AI Chat Assistant](07_ai_chat_assistant.md). diff --git a/docs/user_manual/07_ai_chat_assistant.md b/docs/user_manual/07_ai_chat_assistant.md index 2aef3b5d..22636dbc 100644 --- a/docs/user_manual/07_ai_chat_assistant.md +++ b/docs/user_manual/07_ai_chat_assistant.md @@ -65,12 +65,12 @@ Clicking the Chat Settings button reveals a panel below the header where you can At the bottom of the chat panel is a two-part composer area: -| Element | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Message textarea** | Auto-growing text input. Press **Enter** to send, **Shift+Enter** to insert a newline without sending. | -| **Send** button (round, Send icon) | Submits the message. Disabled while the AI is generating a response. | -| **Stop generation** (red Close icon, dashed border) | Appears while the AI is generating. Click it to immediately abort the streaming response. The partial response remains visible in the message list. | -| **Regenerate last response** (Refresh icon dashed border button) | Re-runs the last AI request from scratch and replaces the previous response. Useful when the output was unsatisfactory. | +| Element | Description | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Message textarea** | Auto-growing text input. Press **Enter** to send, **Shift+Enter** to insert a newline without sending. | +| **Send** button (round, Send icon) | Submits the message. Disabled while the AI is generating a response. | +| **Stop generation** (red Close icon, dashed border) | Appears while the AI is generating. Click it to immediately abort the streaming response. The partial response remains visible in the message list. | +| **Regenerate last response** (Refresh icon dashed border button) | Re-runs the last AI request from scratch and replaces the previous response. Useful when the output was unsatisfactory. | --- @@ -210,4 +210,4 @@ When those actions involve prose, CHAT should orchestrate them instead of writin --- -Next up: Customize the look and feel in [Appearance and Display](08_appearance_and_display.md). +Next up: Find and replace text across your project in [Search and Replace](08_search_and_replace.md). diff --git a/docs/user_manual/08_appearance_and_display.md b/docs/user_manual/08_appearance_and_display.md index 112635c1..3c604be9 100644 --- a/docs/user_manual/08_appearance_and_display.md +++ b/docs/user_manual/08_appearance_and_display.md @@ -59,11 +59,11 @@ The toolbar at the top of the dialog offers two ways to inspect logs: ### Toolbar Actions -| Button | Description | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Refresh** (Refresh icon) | Re-fetches the log data from the server. Use this if you have been running requests in another browser tab. | -| **Clear** (Trash icon) | Deletes all stored log entries after confirmation. | -| **Close** (Close icon) | Closes the dialog. | +| Button | Description | +| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Refresh** (Refresh icon) | Re-fetches the log data from the server. Use this if you have been running requests in another browser tab. | +| **Clear** (Trash icon) | Deletes all stored log entries after confirmation. | +| **Close** (Close icon) | Closes the dialog. | ### LLM Raw Log Verbosity @@ -94,4 +94,4 @@ JSON objects and arrays are collapsible — click the chevron next to any key to --- -Next up: Apply everything you've learned in the [Tutorial: Writing Your First Story](09_tutorial_first_story.md). +Next up: Read a strategic overview of the writing process in [Writing Your Story: A Practical Roadmap](10_writing_a_story.md). diff --git a/docs/user_manual/12_search_and_replace.md b/docs/user_manual/08_search_and_replace.md similarity index 94% rename from docs/user_manual/12_search_and_replace.md rename to docs/user_manual/08_search_and_replace.md index 365d4b10..24381d7d 100644 --- a/docs/user_manual/12_search_and_replace.md +++ b/docs/user_manual/08_search_and_replace.md @@ -107,11 +107,11 @@ This is especially useful when a character is renamed and you want the name upda ## Using Search & Replace via AI Chat -The AI assistant has two built-in tools for search and replace that you can invoke conversationally: +The AI assistant exposes search/replace through a single manager tool that you can invoke conversationally: -### `search_in_project` +### `search_and_replace` (action=`search`) -Ask the AI to find text and it will use the **`search_in_project`** tool: +Ask the AI to find text and it will use **`search_and_replace`** with `action="search"`: > _"Find all mentions of Elena in my chapters."_ > _"Search for the word 'sword' across everything."_ @@ -119,7 +119,7 @@ Ask the AI to find text and it will use the **`search_in_project`** tool: The tool returns a list of matches grouped by section, which the AI formats into a readable reply. -### `replace_in_project` +### `search_and_replace` (action=`replace`) Ask the AI to rename a character or fix a recurring phrase: @@ -141,3 +141,7 @@ The tool runs a global replace and reports how many occurrences were changed and | Find name variants of Elara | Phonetic enabled, All scope | `Elara` | | Rename a character across all chapters | Replace All, All Chapters scope | old name → new name | | Check if any chapter summary lacks a date | Metadata scope | `[0-9]{4}` | + +--- + +Next up: Manage your project's visual assets in [Project Images](09_project_images.md). diff --git a/docs/user_manual/09_project_images.md b/docs/user_manual/09_project_images.md new file mode 100644 index 00000000..28d30a07 --- /dev/null +++ b/docs/user_manual/09_project_images.md @@ -0,0 +1,97 @@ +# Project Images + +The **Project Images** dialog is where you manage all visual assets attached to a story — reference artwork, character portraits, location sketches, or any images you want the AI to describe or use in prompts. Open it from the header by clicking the Image icon Image icon on the right side of the top bar. + +`[SCREENSHOT: The Project Images dialog showing the settings accordion, the action bar with three buttons, and an image grid with cards]` + +--- + +## Project Image Settings + +At the top of the dialog is a collapsible **Project Image Settings** accordion. Click the header row (or the chevron icon) to expand or collapse it. + +| Setting | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Global Style** | A free-text field for a style that should apply to all AI-generated image prompts in this project (e.g. "watercolor painting", "dark fantasy digital art", "pencil sketch"). When you generate prompts for images, this style description is automatically included. | +| **Additional Information** | A textarea for extra hints, negative prompts, or LoRA trigger words that should be appended to every generated prompt. Useful for model-specific instructions (e.g. "no anime style, no text in image"). | + +--- + +## Action Bar + +Below the settings section is a row of three action buttons: + +| Button | Icon | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Generate Placeholder Prompts** | Sparkles icon | Scans the project for all placeholder image slots (images that have a title/description but no uploaded file) and generates an AI image-generation prompt for each one in a single batch. Uses the Message Square icon Blue swatch CHAT model and incorporates the Global Style and Additional Information settings. | +| **Create Placeholder** | Plus icon | Adds a new named placeholder slot to the image grid. A placeholder has a title and description but no image file yet — use it to plan artwork you intend to generate or commission later. | +| **Upload New Image** | ↑ Upload | Opens the system file picker. Accepts PNG, JPEG, GIF, and WEBP files. The uploaded image appears immediately in the grid. | + +You can also drag and drop an image file directly anywhere onto the dialog to upload it. + +--- + +## Image Grid + +Uploaded and placeholder images are shown in a responsive grid (1–4 columns depending on screen width). Each image card contains: + +`[SCREENSHOT: A single image card showing the thumbnail, title input, description textarea, and action buttons]` + +### Thumbnail Area + +- **Click the thumbnail** to open the full-size **Lightbox** view (see below). +- **Replace** button (Refresh icon appears on hover): Opens the file picker to swap the image file while keeping the same title and description. +- **Drag-and-drop**: Drag an image file onto a card to replace it. + +### Title and Description + +| Field | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **Title** text input | An editable name for this image (e.g. "Elara — Chapter 3"). Shown below the thumbnail and used when inserting the image into the editor. | +| **Description** textarea | A resizable text area for a written description of the image. The AI uses this description when referencing the image in prompts or chat. | +| **Save** button (Save icon appears when unsaved changes exist) | Writes the title and description to disk. Changes to the text fields are not auto-saved — you must click Save to persist them. | + +### Per-Image Actions + +Four small action buttons appear below the description: + +| Button | Icon | Description | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Insert** | ✎ TextCursor | Inserts a markdown image reference (`![title](url)`) at the current editor cursor position, embedding this image inline in the chapter you are editing. | +| **Generate / Update description** | Wand icon | Asks the AI to generate a written description for this image (requires a multimodal / Vision-capable model to be assigned to the CHAT role). If a description already exists, it is updated. | +| **Create prompt** | Sparkles icon | Generates an image-generation prompt for this specific image. The prompt is shown in the **Generated Prompt popup** (see below) and can be copied to your clipboard. | +| **Delete** | Trash icon | Permanently deletes the image file from the project after a confirmation prompt. | + +--- + +## Lightbox + +Clicking a thumbnail opens the **Lightbox** — a full-screen overlay showing the image at maximum size (up to 90 % of the screen width and height). The filename and description appear as a caption below the image. Click the **✕** (Close icon) button or the backdrop to close it. + +--- + +## Generated Prompt Popup + +When you click **Create prompt** on an image card, a popup panel appears showing the AI-generated image-generation prompt. The prompt streams in word by word so you can see it being written. + +| Control | Description | +| ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| **Close** (Close icon) | Closes the popup without copying. | +| **Output text area** | Read-only display of the generated prompt. Updates in real time while streaming. | +| **Generating…** indicator | A spinner shown while the generation is still in progress. | +| **Copy to Clipboard** (Copy icon) | Copies the full prompt text. The button label changes to "Copied!" for two seconds as confirmation. | + +Paste the copied prompt into your preferred image generation tool (Stable Diffusion, Midjourney, DALL-E, etc.) to create the artwork. + +--- + +## Tips for Best Results + +- Fill in **Title** and **Description** for every image before generating prompts — the richer the description, the more accurate the generated prompt. +- Use the **Global Style** field to keep a consistent art style across all generated prompts for the project. +- **Placeholders** are powerful for planning: create them and name them (e.g. "Cover art — night scene") before you have the final images, then generate prompts later when you are ready. +- Link images to Sourcebook entries (via the [Sourcebook Image Picker](05_sourcebook.md#image-picker)) so the AI knows which visual reference belongs to which character or location. + +--- + +Next up: Customize the look and feel in [Appearance and Display](10_appearance_and_display.md). diff --git a/docs/user_manual/09_tutorial_first_story.md b/docs/user_manual/09_tutorial_first_story.md index 96c018b3..9375c56b 100644 --- a/docs/user_manual/09_tutorial_first_story.md +++ b/docs/user_manual/09_tutorial_first_story.md @@ -98,7 +98,7 @@ The suggestion cards appear — click any card to insert that text, or press `Es `[SCREENSHOT: The suggestion footer open with continuation cards based on the current chapter context]` -## Step 5b: Adding Visual References +## Step 6: Adding Visual References Visual references help both you and the AI stay consistent with how characters and places look. @@ -111,7 +111,7 @@ Visual references help both you and the AI stay consistent with how characters a `[SCREENSHOT: Project Images dialog showing the Elara placeholder card with the generated prompt popup open]` -## Step 6: Pacing and Review +## Step 7: Pacing and Review Once you finish the chapter, it's time to review. @@ -123,7 +123,7 @@ Pacing is how fast or slow the story feels. Action scenes should have shorter se 2. Type: _"Please review Chapter 1. How is the pacing? Is the conflict clear?"_ 3. The AI will analyze your text and provide constructive feedback. -## Step 7: Set Up Your Writing Environment +## Step 8: Set Up Your Writing Environment Before your next long writing session, take a moment to tune the appearance to your preferences. @@ -140,4 +140,6 @@ Congratulations! You have set up a project, built your world in the Sourcebook, Remember, AugmentedQuill is a tool to enhance _your_ creativity. The story is always yours. Keep writing, keep experimenting, and let your imagination run wild! -_Return to the [User Manual index](index.md) to explore any topic in more depth._ +--- + +Next up: Learn how to use your AI writing partner in [The AI Chat Assistant](07_ai_chat_assistant.md). diff --git a/docs/user_manual/10_appearance_and_display.md b/docs/user_manual/10_appearance_and_display.md new file mode 100644 index 00000000..c8397b7d --- /dev/null +++ b/docs/user_manual/10_appearance_and_display.md @@ -0,0 +1,97 @@ +# Appearance and Display + +AugmentedQuill gives you fine-grained control over how the writing environment looks and feels. The **Appearance popup** lets you adjust the visual theme, typography, and panel sizes. The **Debug Logs** overlay (for advanced users) lets you inspect every AI request your session has made. + +--- + +## Appearance Popup + +Click the Type icon **Appearance** button (the "Aa" / Type icon) on the right side of the top header bar and a dropdown panel appears below it. + +`[SCREENSHOT: The Appearance popup open, showing the Design Mode toggle and the five sliders]` + +### Design Mode + +A three-segment toggle at the top of the popup controls the overall color theme: + +| Option | Description | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Light** | The editor paper is bright white and all panels use a light color scheme. Best for daylight or well-lit rooms. | +| **Mixed** | The editor paper stays light but the surrounding panels (sidebar, header, chat pane) use darker tones. A good balance for long writing sessions. | +| **Dark** | All panels and the editor use dark backgrounds. Best for low-light environments or writers who prefer less screen glare. | + +The mode takes effect immediately across the entire application. + +### Appearance Sliders + +Five range sliders below the Design Mode toggle let you tune the reading and writing environment to your preferences. All changes are applied instantly as you drag. + +| Slider | Range | Default | Effect | +| ----------------- | ---------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Brightness** | 50 – 100 | 100 | Controls the brightness of the editor paper area. Lower values create a slightly off-white page that is easier on the eyes during long sessions. | +| **Contrast** | 50 – 100 | 100 | Controls the contrast of the text against the background. Reducing this slightly can soften harsh black-on-white text. | +| **Font Size** | 12 – 32 px | 16 px | Sets the base font size of the editor text. Increase for comfortable reading on large monitors; decrease to see more text at once. | +| **Line Width** | 40 – 100 ch | 70 ch | Sets the maximum width of the editor column, measured in characters. Narrower lines (50–65 ch) are considered easier to read for long-form prose; wider lines let you see more text per line. | +| **Sidebar Width** | 200 – 600 px (step 10) | 320 px | Controls the width of the left sidebar (and the right chat panel). Increase this if your project has long chapter titles or many Sourcebook entries that need more horizontal space. | + +### Closing the Popup + +Click the **✕** (Close icon) button in the top-right corner of the popup, or click anywhere outside it, to close the Appearance panel. All changes are preserved automatically. + +--- + +## Debug Logs + +The **Debug Logs** dialog is a developer-focused tool that shows a full transcript of every AI request AugmentedQuill has sent during the current session. It is useful for diagnosing unexpected AI behavior, verifying what context is being sent, or comparing request and response data. + +Open it by clicking the **Bug** icon on the right side of the top header bar. + +`[SCREENSHOT: Debug Logs dialog showing the aggregated view with a list of request entries, one expanded to show the request and response JSON]` + +### View Modes + +The toolbar at the top of the dialog offers two ways to inspect logs: + +| Mode | Icon | Description | +| -------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Aggregated** | Layers icon | Shows each request as a clean summary: the full assembled response text and any tool calls. Best for quickly reading what the AI said. | +| **Chunks** | List icon | Shows every raw JSON streaming chunk received from the API. Best for deep debugging of streaming issues or token usage. | + +### Toolbar Actions + +| Button | Description | +| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Refresh** (Refresh icon) | Re-fetches the log data from the server. Use this if you have been running requests in another browser tab. | +| **Clear** (Trash icon) | Deletes all stored log entries after confirmation. | +| **Close** (Close icon) | Closes the dialog. | + +### LLM Raw Log Verbosity + +The backend writes raw LLM interaction data to `data/logs/llm_raw.log` when `AUGQ_LLM_DUMP=1` is set in your environment. You can adjust the verbosity using `AUGQ_LLM_DUMP_LEVEL`: + +- `compact` (default): writes one entry per communication with minimal request/response payload, streaming data is summarized into `chunk_count` and `full_content_summary` with truncated chunks. +- `normal`: writes one entry per communication with reduced payload, includes streaming chunk previews (up to 20 truncated items) and non-streaming response text up to 400 characters. +- `debug`: includes full request/response objects and full streaming chunks, useful only for deep network and protocol debugging. + +The compact mode is recommended for normal use because it keeps log files readable while preserving every communication event. + +### Log Entries + +Each entry in the log list is a collapsible row. The collapsed row shows: + +- An **HTTP method badge** (POST in green, other methods in blue). +- A **model type badge** indicating which role made the call: Pen icon EDITING (purple), Book Open icon WRITING (blue), or Message Square icon CHAT (orange). +- The **API endpoint name** (e.g. `/api/v1/chat/completions`). +- The **HTTP status code** (200 in green, errors in red). +- **Start and end timestamps** with elapsed time. + +Click a row to expand it. The expanded view shows two collapsible sections: + +- **Request**: An interactive JSON tree showing the full payload sent to the model, including the system prompt, user messages, tool definitions, and any image attachments. +- **Response**: An interactive JSON tree showing the raw response (Chunks mode) or the assembled text and tool call results (Aggregated mode). + +JSON objects and arrays are collapsible — click the chevron next to any key to expand or collapse that branch. This makes it easy to navigate large payloads without scrolling through thousands of lines. + +--- + +Next up: Read a strategic overview of the writing process in [Writing Your Story: A Practical Roadmap](11_writing_a_story.md). diff --git a/docs/user_manual/10_writing_a_story.md b/docs/user_manual/10_writing_a_story.md index 87a47cf2..dd4200dd 100644 --- a/docs/user_manual/10_writing_a_story.md +++ b/docs/user_manual/10_writing_a_story.md @@ -223,3 +223,7 @@ Writing a novel is a big commitment. These percentages are rough estimates of ** - **Phase 5 (Polishing): 10%** > 🧠 Tip: If you’re using AugmentedQuill, the AI tools are designed to help you trim time from the Drafting and Editing phases, but the biggest gains come from having a solid plan and clear story goals. + +--- + +Next up: Plan advanced story structure in [Scenes, Annotations, and Structural Improvements](13_scenes_and_annotations.md). diff --git a/docs/user_manual/11_writing_a_story.md b/docs/user_manual/11_writing_a_story.md new file mode 100644 index 00000000..9369323c --- /dev/null +++ b/docs/user_manual/11_writing_a_story.md @@ -0,0 +1,229 @@ +# Writing Your Story: A Practical Roadmap + +Writing a long-form story (novel, serial, or campaign) is a marathon, not a sprint. This chapter walks through the common phases of getting from a flicker of an idea to a polished manuscript, and where AugmentedQuill can help you stay productive, organized, and creatively focused. + +> ⚠️ **No tool can write your story for you.** What AugmentedQuill does is make the parts of writing that are repetitive, thorny, or easy to procrastinate on feel less painful, so you can spend more of your energy on the parts that matter most: _the story itself_. + +--- + +## Phase 1: The Seed (Ideation & Conceptualization) + +Before you write a single chapter, you need a clear idea of what your story is about. + +### 1. Brainstorm Core Concepts + +Ask yourself: + +- What genre am I writing? (fantasy, mystery, romance, etc.) +- What are the big themes (loss, power, redemption, freedom)? +- What is the core “what if?” question that drives the story? + +#### AugmentedQuill Tip + +Use the **AI Chat Assistant** (see [The AI Chat Assistant](07_ai_chat_assistant.md)) to brainstorm ideas, list trope variations, or quickly compare different high-level concepts. + +### 2. Write a Logline (Elevator Pitch) + +Try to distill the story into 1–2 sentences: + +> “A [protagonist] must [goal] before [stakes].” + +This logline becomes the compass you return to whenever your plot feels like it’s drifting. + +#### AugmentedQuill Tip + +Write your logline in the **Story Notes** (Story Metadata notes). Notes are included in every AI generation request, so keeping the logline there ensures the model always remembers the core goal and stakes. + +### 3. Define the Conflict + +Conflict is the engine of story. Put it on paper: + +- What does the protagonist want? +- What is blocking them? +- What happens if they fail? + +Make the stakes personal and tangible (lose a home, fail a mission, break a relationship). + +#### AugmentedQuill Tip + +Start by writing the overall conflict in your **Story Notes** (Story Metadata notes). Once it’s clear, add the most important pieces into Sourcebook character entries (e.g., the antagonist’s goals, the protagonist’s stakes, and key relationship tensions) so the AI has them on-hand when generating or rewriting text. + +--- + +## Phase 2: The Blueprint (Planning & Outlining) + +Even writers who prefer “pantsing” usually benefit from some planning. A little structure up front can save huge amounts of rewriting later. + +### 4. Character Creation + +Build your core cast: + +- **Protagonist**: What do they want? What are they afraid of? +- **Antagonist**: What do they stand for? Why are they opposed to the protagonist? +- **Supporting cast**: Who helps and who hurts, and why? + +Give them flaws, desires, and arcs (how they change over the story). + +#### AugmentedQuill Tip + +Use the **Sourcebook** to keep character profiles, relationship notes, and recurring details. When you ask the AI to extend or rewrite, AugmentedQuill includes this context so the model stays consistent. + +### 5. World-Building + +Establish the setting and the rules that matter for your story: + +- What is the world like (city, wilderness, space ship)? +- Are there special systems (magic, technology, factions)? +- How does society work and what tensions are in play? + +#### AugmentedQuill Tip + +For complex settings, create one or more Sourcebook entries for “World,” “Magic Rules,” “Culture,” etc. This keeps reference material handy and ensures the AI uses the same details across chapters. + +### 6. Choose a Plot Structure + +Pick a framework to hang your story on. Common choices: + +- **Three-Act Structure** (Setup → Confrontation → Resolution) +- **The Hero's Journey** (Call → Trials → Return) +- **Save the Cat!** (Beat sheet approach) + +Mapping major beats (inciting incident, midpoint, climax) helps you maintain momentum and avoid wandering. Record your structure in the **Story Notes** so it stays visible to the AI and is easy to reference as you revise. + +### 7. Create a Chapter Outline + +Start by jotting rough chapter sketches in the **book notes** and the **chapter notes** (Metadata) for each entry. These notes are easy to revise as your story evolves, and they are sent to the AI on every generation call, helping keep the narrative consistent. + +Once your rough sketches feel like they hang together, distill them into the chapter summary field (the “summary” in each chapter’s metadata). That summary is what the AI uses as a guide when you ask it to generate or continue a chapter. + +#### AugmentedQuill Tip + +Use **Chapters and Books** (see [Chapters and Books](04_chapters_and_books.md)) to manage your outline. The notes area is your scratchpad; the summary field is the polished version that the AI uses to stay on-track. + +--- + +## Phase 3: The Execution (Drafting) + +This is where you actually put words on the page. + +### 8. Set a Writing Schedule & Goal + +Decide on a realistic daily or weekly target (e.g., 500 words a day, one chapter a week) and stick to it. Small, consistent progress is almost always better than sporadic bursts. + +### 9. Write the “Vomit Draft” (First Draft) + +The main goal is momentum. Don’t polish as you go. + +- If you get stuck on a detail, drop a placeholder like `[NAME]` or `[PLACEHOLDER]` and move on. +- Let the first draft be rough; you’ll fix it later. + +#### AugmentedQuill Tip + +Use the **Extend** and **Suggest** buttons in the editor to keep your momentum. They’re designed to help you overcome writer’s block by generating the next sentence, paragraph, or chunk of text based on your current chapter and the project context. + +### 10. Manage Chapter Pacing + +A good chapter usually has: + +- A small goal (what the character is trying to achieve in this scene) +- A conflict or obstacle +- A hook or decision that leads into the next chapter + +If a chapter stalls, it’s often because the internal goal isn’t clear. + +--- + +## Phase 4: The Refinement (Editing & Revising) + +Once the draft exists, the real work begins. + +### 11. Step Away + +Put the manuscript aside for at least 2–4 weeks. Fresh eyes make it easier to spot structural problems. + +### 12. The Developmental Edit (Big Picture) + +Read the whole draft and look for: + +- Plot holes or inconsistencies +- Slow pacing or “dead air” scenes +- Characters acting out of character + +This stage may require rewriting entire chapters or re-ordering scenes. + +#### AugmentedQuill Tip + +Use the **AI Chat Assistant** to ask questions like “What are the biggest plot holes in this outline?” or “What motivations should a protagonist have in Act 2?” It can act as a sounding board as you revise. + +### 13. The Line Edit (Flow & Voice) + +Now focus on sentence-level quality: + +- Improve word choice +- Cut redundant phrases +- Make dialogue sound natural + +#### AugmentedQuill Tip + +In AugmentedQuill, the **Chat** model usually invokes the **EDITING** model behind the scenes when you ask it to polish or rewrite text. Use the AI Write / Update / Rewrite buttons in the chapter metadata to let the chat assistant call the editing model to improve summaries or rewrite a paragraph with a different tone or voice. + +### 14. Beta Readers + +Share your draft with a few trusted readers and ask for specific feedback: + +- Where did you get bored? +- Which characters did you care about? +- What confused you? + +Collect the feedback and decide what to act on. + +### 15. Revise Again + +Apply the useful feedback and tighten the manuscript. Revision is where a good story usually becomes a great one. + +--- + +## Phase 5: The Final Polish (Finishing Touches) + +The story is solid; now make it clean and presentable. + +> Note: At the time of writing, AugmentedQuill does not have a comprehensive keyboard shortcut guide and relies primarily on UI interactions. + +### 16. Copyediting & Proofreading + +Hunt down typos, grammar issues, and formatting problems. This is tedious, but it makes a big difference. + +#### AugmentedQuill Tip + +Use the **AI Chat Assistant** or the **EDITING model** to proofread a passage, fix punctuation, or suggest cleaner phrasing. + +### 17. Final Formatting + +Ensure your chapters have clear headings, consistent spacing, and clean markdown. If you plan to export to other formats, check that the structure is compatible with your chosen toolchain. + +### 18. Decide Your Path + +Choose how you’ll share your work: + +- **Traditional publishing:** Write a query letter and synopsis, research agents, and prepare submission materials. +- **Self-publishing:** Design a cover, format for e-readers, and upload to platforms like Kindle Direct Publishing. +- **Export an eBook:** AugmentedQuill can compile your project into an `.epub` file via the Projects tab Export (EPUB) action, giving you a ready-to-share eBook. +- **Web serialization:** Post chapters on platforms like Wattpad, RoyalRoad, or your own site. + +--- + +## Rough Effort Breakdown (What to Expect) + +Writing a novel is a big commitment. These percentages are rough estimates of **active working time** (not calendar time): + +- **Phase 1 (Ideation): 5%** +- **Phase 2 (Planning): 15%** +- **Phase 3 (Drafting): 40%** +- **Phase 4 (Editing): 30%** +- **Phase 5 (Polishing): 10%** + +> 🧠 Tip: If you’re using AugmentedQuill, the AI tools are designed to help you trim time from the Drafting and Editing phases, but the biggest gains come from having a solid plan and clear story goals. + +--- + +Next up: Plan advanced story structure in [Scenes, Annotations, and Structural Improvements](12_scenes_and_annotations.md). diff --git a/docs/user_manual/12_scenes_and_annotations.md b/docs/user_manual/12_scenes_and_annotations.md new file mode 100644 index 00000000..5d234256 --- /dev/null +++ b/docs/user_manual/12_scenes_and_annotations.md @@ -0,0 +1,295 @@ +# Scenes, Annotations, and Structural Improvements + +> **Introduced in:** v0.9+ + +This chapter covers the major new features: the **Scenes** system (for planning and structuring stories at the narrative-unit level), **inline Annotations** (for notes attached to specific prose), the **Convergence Map** (for time-travel and multi-timeline stories), and various other structural improvements. + +--- + +## Table of Contents + +1. [What Are Scenes?](#what-are-scenes) +2. [Workspace Modes](#workspace-modes) +3. [The Scenes Views](#the-scenes-views) + - [Narrative View](#narrative-view) + - [Pinboard View](#pinboard-view) + - [Convergence Map](#convergence-map) +4. [Creating and Managing Scenes](#creating-and-managing-scenes) + - [Scene Properties](#scene-properties) + - [Causal Ordering](#causal-ordering) + - [Scene Beats](#scene-beats) +5. [The Scene Editor Dialog](#the-scene-editor-dialog) +6. [Inline Annotations](#inline-annotations) +7. [Scene Sourcebook Tags](#scene-sourcebook-tags) +8. [Sidebar Enhancements](#sidebar-enhancements) +9. [Search and Replace in Scenes](#search-and-replace-in-scenes) +10. [AI Chat Integration](#ai-chat-integration) +11. [Diff Review Improvements](#diff-review-improvements) +12. [Sourcebook Time Travel Features](#sourcebook-time-travel-features) +13. [View State Persistence](#view-state-persistence) + +--- + +## What Are Scenes? + +A **Scene** is a narrative unit — a building block of your story that may span part of a chapter, an entire chapter, or remain unlinked from any prose (planning-only). Scenes allow you to: + +- Plan your story's structure **before writing** any prose. +- Track **causal relationships** between story events (what causes what). +- Attach **sourcebook characters** to each scene. +- Assign **temporal** (date/time) and **location** metadata. +- Organize scenes into **timelines** for time-travel or parallel narratives. +- Visualize your story through multiple lenses: narrative order, free-form pinboard, or a convergence map with character arc snakes. + +### Narrative Units vs. Prose + +A scene is a **planning unit**, not a prose unit. Multiple scenes can exist within a single chapter, a scene can span multiple chapters, or a scene can exist purely as a plan without any prose attached. This separation lets you outline your story's structure independently of your actual writing. + +--- + +## Workspace Modes + +Click the **workspace mode buttons** in the top header bar to switch between layouts: + +| Mode | Icon | What you see | +| ---------- | ---------- | ------------------------------------------------------------ | +| **Page** | `FileText` | Traditional editor + left sidebar + right chat panel | +| **Scenes** | `BookOpen` | Editor is replaced by the full Scenes panel | +| **Split** | `Columns` | Editor on the left, Scenes panel on the right — side by side | + +--- + +## The Scenes Views + +Once you're in **Scenes** or **Split** mode, use the segmented buttons labelled **Pinboard**, **Narrative**, **Chronological**, and **Convergence Map** to switch between view types. + +### Narrative View + +The **Narrative View** (default) displays all scenes as a vertical list. Divider lines appear at chapter and book boundaries so you can see how scenes map onto your story structure. + +- Scenes without any linked prose are collected at the bottom. +- **Click** any scene card to select it and highlight its prose in the editor. +- **Drag and drop** a scene card to reorder it (this adjusts the causal relationships). +- Multi-select: **Ctrl+click** to toggle individual scenes, **Shift+click** for range selection. + +### Pinboard View + +The **Pinboard View** provides an infinite canvas where you can freely position scene cards. + +- **Pan:** Middle-mouse button drag, or **Alt+drag** on the background. +- **Zoom:** Scroll wheel. +- **Move cards:** Drag any card to reposition it. +- **Multi-card drag:** Drag one selected card to move all selected cards together. +- **Link scenes:** **Alt+drag** from one card toward another to draw a causal arrow — a ghost arrow preview appears as you drag. +- **Lasso select:** Drag on empty space to draw a selection rectangle. +- Same multi-select shortcuts as Narrative View (**Ctrl+click**, **Shift+click**). + +### Convergence Map + +The **Convergence Map** is designed for **time-travel stories**. It shows scenes sorted by their in-story time, with one coloured snake path per sourcebook character drawn behind the cards. + +- Each character or sourcebook entry gets its own **coloured lane** at the top. +- The snake shows the character's **experience order** through the story. +- Time-travel jumps appear as **U-turns** in the snake path. +- Scene cards are positioned by chronological story time, not narrative order. +- Click a character's lane button to highlight their path. + +--- + +## Creating and Managing Scenes + +### Creating a Scene + +You can create scenes in several ways: + +1. **AI Chat:** Ask the assistant to create a new scene (e.g., _"Create a new scene where Elena meets the stranger."_) +2. **Detect Boundaries:** Ask the AI to analyse your existing prose and **automatically detect scene boundaries**, creating linked scenes from the result. +3. **Write a Scene:** Ask the AI to write prose for a specific scene, optionally within a particular chapter. + +### Scene Properties + +Each scene card displays key information. You can edit all properties in the Scene Editor Dialog. + +| Property | Description | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| **Summary** | A one-line description of what happens in the scene. | +| **Status** | One of: Active, Draft, Completed, Paused, or Cut. | +| **Location** | Where the scene takes place (free text). | +| **Time / Scene Time** | When the scene takes place (date and time). | +| **Timeline** | Which timeline this scene belongs to. Default is "main"; for time-travel branches, you can create custom timeline IDs. | +| **Color Tag** | A coloured border for visual organization — choose from red, orange, yellow, green, teal, blue, purple, or pink. | +| **Active Characters** | Characters who are present and active in the scene. | +| **Passive Characters** | Characters who are present but not directly acting. | +| **Causes** | Which other scenes this one is caused by (determines narrative order). | + +### Causal Ordering + +Scenes are ordered by their **causal relationships**, not by arbitrary numbers. Each scene lists what it depends on, forming a cause-and-effect chain. + +- The system automatically detects **ordering violations** — scenes that should logically come before the scenes that cause them — and shows an **`AlertTriangle` icon** on the card. +- **Temporal violations** are also flagged: if a scene's time is _before_ the scene that causes it, you'll see a **`Clock3` icon**. + +### Scene Beats + +Each scene can contain **Beats** — smaller sub-units of action within the scene. Beats help you break down a scene into its key moments (e.g., "Elena arrives", "The argument begins", "The stranger leaves") and can each link to their own section of prose. + +--- + +## The Scene Editor Dialog + +**Double-click** any scene card, or ask the AI to edit a scene, to open the **Scene Editor Dialog**. This provides a full form where you can: + +- Edit the **summary**, **location**, **time**, and **status**. +- Manage **Active** and **Passive character** lists. +- Assign a **color tag** for visual organization. +- Adjust the **pinboard position** (X/Y coordinates). +- View and edit **causal relationships** (which scenes this one follows or precedes). +- Set a **date/time** value by clicking the clock button to open the date/time picker. + +--- + +## Inline Annotations + +**Annotations** let you attach notes, comments, or reminders to specific ranges of text. They appear as coloured highlights in the editor. + +### Adding an Annotation + +1. **Select** the text you want to annotate in the editor. +2. Press **Ctrl+Shift+A** (Windows/Linux) or **Cmd+Shift+A** (macOS) — or click the **`MessageSquare` icon** in the editor toolbar. +3. Type your comment in the dialog and confirm. + +### Managing Annotations + +Open the **Annotation Sidebar** (click the **`MessageSquarePlus` icon** in the right-side panel area). It lists all annotations for the current chapter: + +- **Click** an annotation entry to scroll the editor to it and highlight the annotated range. +- Use the **`Pencil` icon** to edit a comment. +- Use the **`Trash2` icon** to delete an annotation. +- The list refreshes automatically when you switch chapters. + +### Keyboard Shortcuts + +| Shortcut | Action | +| ---------------------------------- | ---------------------------------- | +| **Ctrl+Shift+A** / **Cmd+Shift+A** | Create annotation on selected text | +| **Escape** | Close the annotation dialog | + +--- + +## Scene Sourcebook Tags + +Characters and sourcebook entries can now carry a **birth or origin date**. When you attach such an entry to a scene, the system can display the character's **age** at the time of the scene, computed from their origin date and the scene's time. + +### Per-Scene Personal Datetime Overrides + +For time-travel stories, each character tag on a scene can optionally override the character's personal "now" for that specific scene. This allows the same character to appear multiple times in one scene (e.g., a time traveller meeting their younger self) — each instance can show a different age. + +--- + +## Sidebar Enhancements + +The left sidebar (containing Story, Chapters, and Sourcebook sections) received several improvements: + +- **Resizable sections** — Drag the **`GripHorizontal` icon** (grip lines) between sections to resize them. +- **Section focus** — Click the **`Maximize2` icon** in a section's header to focus that section (it expands fully, collapsing others). Click the **`Minimize2` icon** to return to the multi-section view. +- **Scene tree in chapters** — Expand a chapter node to see its assigned scenes listed underneath in a compact tree view, using the **`ChevronRight` / `ChevronDown` icons** to expand and collapse. +- **Drag scenes onto chapters** — Drag a scene card from the scenes panel and drop it onto a chapter in the sidebar to link them. +- **Desktop behaviour** — On wide screens, selecting a chapter no longer closes the sidebar. + +--- + +## Search and Replace in Scenes + +The Search & Replace system (accessible via the AI chat or the search panel) can now search inside scene metadata: + +- **Scene summaries** — Find and replace text in scene descriptions. +- **Scene locations** — Search or replace location names across all scenes. +- **Scene times** — Find temporal values. +- **Timeline IDs** — Search timeline identifiers. +- **Character lists** — Find or replace character names in active/passive lists. + +--- + +## AI Chat Integration + +### Scene Chat Tools + +The AI assistant can help you manage scenes conversationally. Just ask in natural language: + +> _"Create a new scene where Elena discovers the hidden letter."_ +> +> _"List all scenes involving Marcus."_ +> +> _"Move the market scene after the forest scene."_ +> +> _"Write the prose for scene 5 inside chapter 3."_ +> +> _"Detect scene boundaries in my current chapter."_ + +New tools available to the assistant include: + +| What you can ask for | What the AI does | +| -------------------- | ------------------------------------------------------- | +| Create a scene | Adds a new scene with summary, location, and characters | +| Update a scene | Changes summary, time, location, status, etc. | +| Delete a scene | Removes a scene and its markers from prose | +| List scenes | Shows all scenes with their summaries and status | +| Get scene details | Shows all properties of a single scene | +| Reorder scenes | Adjusts causal relationships to change narrative order | +| Link scene to prose | Attaches a scene to a specific content scope | +| Write scene prose | Generates prose content for a scene via the Writing LLM | +| Detect boundaries | Analyses existing prose and auto-creates linked scenes | + +### Project Creation Confirmation + +When the AI creates a new project, you'll be asked to **confirm** if the project name wasn't specified upfront. + +### Chat Session Preservation + +When you switch to a different project, your current chat session is automatically saved and associated with that project. When you return, the conversation picks up where you left off. + +### Context Usage Display + +The chat header now shows two context usage meters: your local estimation and the **server-reported usage** (as a percentage of the model's context window), so you know how close you are to the model's token limit. + +--- + +## Diff Review Improvements + +When the editor shows highlighted changes (e.g., after an AI rewrite or undo), several new tools help you review and accept or reject changes. + +### Floating Accept/Reject Toolbar + +Hover over any diff-highlighted section in the editor — a small toolbar appears with: + +- **`Check` icon** — Accept this individual change +- **`X` icon** — Reject this change (revert to the original) +- **`CheckCheck` icon** — Accept all changes (shown when multiple diffs exist) + +### Block Mode for Large Rewrites + +When the AI substantially rewrites a section, the diff switches to **block mode**: the old and new versions are shown side by side as whole blocks, making it easier to review large changes compared to word-level inline highlighting. + +--- + +## Sourcebook Time Travel Features + +Sourcebook entries now support a **Time Travel** category with additional fields for managing temporal stories. + +### New Sourcebook Fields + +When editing a sourcebook entry of the "Time Travel" category, you'll see these additional fields: + +| Field | What it's for | +| -------------------------- | ------------------------------------------------------------------------------------ | +| **Origin Date** | Birth or creation date of the entry — used to compute a character's age at any scene | +| **Destination Date/Time** | For time-travel entries: the absolute date/time the character travels to | +| **Destination (Relative)** | A relative description (e.g., "1000 years in the past") | +| **Creates New Timeline** | Check this if the entry creates a branching timeline | +| **Timeline ID** | A stable identifier for this timeline (auto-filled for branches) | + +### Relation Scoping to Scenes + +## Sourcebook relations are now scoped to individual **scenes** instead of whole chapters. When you define a relationship between entries (e.g., "Elena knows Marcus"), you can specify which scene the relationship starts and ends at, giving you finer control over relationship timelines. + +Next up: Find help in [Troubleshooting & FAQ](13_troubleshooting.md). diff --git a/docs/user_manual/11_troubleshooting.md b/docs/user_manual/13_troubleshooting.md similarity index 100% rename from docs/user_manual/11_troubleshooting.md rename to docs/user_manual/13_troubleshooting.md diff --git a/docs/user_manual/index.md b/docs/user_manual/index.md index e2d5d090..27c58420 100644 --- a/docs/user_manual/index.md +++ b/docs/user_manual/index.md @@ -9,10 +9,11 @@ Welcome to AugmentedQuill, your AI-assisted prose writing companion. This manual 3. [The Writing Interface](03_writing_interface.md) 4. [Chapters and Books](04_chapters_and_books.md) 5. [The Sourcebook](05_sourcebook.md) -6. [Project Images](06_project_images.md) +6. [Tutorial: Writing Your First Story](06_tutorial_first_story.md) 7. [The AI Chat Assistant](07_ai_chat_assistant.md) -8. [Appearance and Display](08_appearance_and_display.md) -9. [Tutorial: Writing Your First Story](09_tutorial_first_story.md) -10. [Writing Your Story: A Practical Roadmap](10_writing_a_story.md) -11. [Troubleshooting & FAQ](11_troubleshooting.md) -12. [Search and Replace](12_search_and_replace.md) +8. [Search and Replace](08_search_and_replace.md) +9. [Project Images](09_project_images.md) +10. [Appearance and Display](10_appearance_and_display.md) +11. [Writing Your Story: A Practical Roadmap](11_writing_a_story.md) +12. [Scenes, Annotations, and Structural Improvements](12_scenes_and_annotations.md) +13. [Troubleshooting & FAQ](13_troubleshooting.md) diff --git a/openapi.json b/openapi.json index 3e6c7206..0c62f28a 100644 --- a/openapi.json +++ b/openapi.json @@ -1771,7 +1771,7 @@ "post": { "tags": ["Story", "Story"], "summary": "Api Story Sourcebook Relevance", - "description": "Ask the WRITING model which sourcebook entries are relevant.\n\nThis is a lightweight helper used by the frontend to keep checkboxes\nin sync. It is deliberately separate from the prose suggestion call so\nthat we can run it in the background on every text change.", + "description": "Return scene-derived sourcebook entries relevant to the active prose end.\n\nThis runs in the background while the user types, so it must stay\ndeterministic, cheap, and free of LLM latency.", "operationId": "api_story_sourcebook_relevance_api_v1_projects__project_name__story_sourcebook_relevance_post", "parameters": [ { @@ -2066,6 +2066,58 @@ } } }, + "/api/v1/projects/{project_name}/chapters/{chap_id}/rewrite-and-relink": { + "post": { + "tags": ["Story", "Story"], + "summary": "Api Chapter Rewrite And Relink", + "description": "Atomically rewrite a chapter and auto-link scenes in one transaction.\n\nThis endpoint streams the generated content and persists it,\nthen performs scene auto-linking on the resulting text.", + "operationId": "api_chapter_rewrite_and_relink_api_v1_projects__project_name__chapters__chap_id__rewrite_and_relink_post", + "parameters": [ + { + "name": "chap_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Chap Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Api Chapter Rewrite And Relink Api V1 Projects Project Name Chapters Chap Id Rewrite And Relink Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/projects/{project_name}/story/title": { "post": { "tags": ["Story", "Story"], @@ -3512,97 +3564,1991 @@ } } }, - "/api/v1/health": { + "/api/v1/projects/{project_name}/scenes": { "get": { - "summary": "", - "operationId": "_lambda__api_v1_health_get", + "tags": ["Scenes"], + "summary": "Get Scenes", + "description": "List all scenes for the project, with staleness flags on prose links.", + "operationId": "get_scenes_api_v1_projects__project_name__scenes_get", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scene" + }, + "title": "Response Get Scenes Api V1 Projects Project Name Scenes Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": ["Scenes"], + "summary": "Create New Scene", + "description": "Create a new scene.", + "operationId": "create_new_scene_api_v1_projects__project_name__scenes_post", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scene" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } }, - "/": { + "/api/v1/projects/{project_name}/scenes/{scene_id}": { "get": { - "summary": " Root Redirect", - "description": "Helper for redirect..", - "operationId": "_root_redirect__get", + "tags": ["Scenes"], + "summary": "Get Single Scene", + "description": "Fetch a single scene by ID.", + "operationId": "get_single_scene_api_v1_projects__project_name__scenes__scene_id__get", + "parameters": [ + { + "name": "scene_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scene Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/Scene" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } - } - } - }, - "components": { - "schemas": { - "Body_api_projects_images_upload_api_v1_projects_images_upload_post": { - "properties": { - "file": { - "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File" - } - }, - "type": "object", - "required": ["file"], - "title": "Body_api_projects_images_upload_api_v1_projects_images_upload_post" }, - "Body_api_projects_import_api_v1_projects_import_post": { - "properties": { - "file": { - "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File" + "put": { + "tags": ["Scenes"], + "summary": "Update Existing Scene", + "description": "Update an existing scene (partial \u2013 only provided fields are changed).", + "operationId": "update_existing_scene_api_v1_projects__project_name__scenes__scene_id__put", + "parameters": [ + { + "name": "scene_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scene Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" } - }, - "type": "object", - "required": ["file"], - "title": "Body_api_projects_import_api_v1_projects_import_post" - }, - "BookCreateRequest": { - "properties": { - "name": { - "type": "string", - "title": "Name" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneUpdateRequest" + } + } } }, - "type": "object", - "required": ["name"], - "title": "BookCreateRequest", - "description": "Represents the BookCreateRequest type." - }, - "BookDeleteRequest": { - "properties": { - "name": { - "type": "string", - "title": "Name" + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scene" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } - }, - "type": "object", - "required": ["name"], - "title": "BookDeleteRequest", - "description": "Represents the BookDeleteRequest type." + } }, - "BookMutationResponse": { - "properties": { + "delete": { + "tags": ["Scenes"], + "summary": "Delete Existing Scene", + "description": "Delete a scene and remove it from all order constraints.", + "operationId": "delete_existing_scene_api_v1_projects__project_name__scenes__scene_id__delete", + "parameters": [ + { + "name": "scene_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scene Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/{scene_id}/link-prose": { + "post": { + "tags": ["Scenes"], + "summary": "Link Scene Prose", + "description": "Assign a prose-text range to a scene using inline file markers.", + "operationId": "link_scene_prose_api_v1_projects__project_name__scenes__scene_id__link_prose_post", + "parameters": [ + { + "name": "scene_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scene Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneLinkProseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scene" + }, + "title": "Response Link Scene Prose Api V1 Projects Project Name Scenes Scene Id Link Prose Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/batch-link-prose": { + "post": { + "tags": ["Scenes"], + "summary": "Batch Link Scene Prose", + "description": "Atomically unlink and relink multiple scenes in one scope.\n\nAll assignments are processed in a single pass so touching boundaries\ndo not get replayed through repeated single-scene edits that can split\nfreshly inserted markers.", + "operationId": "batch_link_scene_prose_api_v1_projects__project_name__scenes_batch_link_prose_post", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneBatchLinkProseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scene" + }, + "title": "Response Batch Link Scene Prose Api V1 Projects Project Name Scenes Batch Link Prose Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/{scene_id}/unlink-prose": { + "post": { + "tags": ["Scenes"], + "summary": "Unlink Scene Prose", + "description": "Remove the prose link from a scene, preserving its narrative position.\n\nReturns all scenes affected in the same prose scope.", + "operationId": "unlink_scene_prose_api_v1_projects__project_name__scenes__scene_id__unlink_prose_post", + "parameters": [ + { + "name": "scene_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scene Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scene" + }, + "title": "Response Unlink Scene Prose Api V1 Projects Project Name Scenes Scene Id Unlink Prose Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/reorder-prose": { + "post": { + "tags": ["Scenes"], + "summary": "Reorder Scene Prose Route", + "description": "Reorder linked prose blocks and persist marker-aware offsets.", + "operationId": "reorder_scene_prose_route_api_v1_projects__project_name__scenes_reorder_prose_post", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneReorderProseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneReorderProseResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/{scene_id}/prose-content": { + "patch": { + "tags": ["Scenes"], + "summary": "Update Scene Prose Content", + "description": "Replace the text between a scene's inline start/end markers.", + "operationId": "update_scene_prose_content_api_v1_projects__project_name__scenes__scene_id__prose_content_patch", + "parameters": [ + { + "name": "scene_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scene Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneUpdateProseContentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scene" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/detect-boundaries": { + "post": { + "tags": ["Scenes"], + "summary": "Detect Boundaries For Scenes", + "description": "Detect scene boundaries in a prose segment and relink affected scenes.", + "operationId": "detect_boundaries_for_scenes_api_v1_projects__project_name__scenes_detect_boundaries_post", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneDetectBoundariesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneDetectBoundariesResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/{scene_id}/write": { + "post": { + "tags": ["Scenes"], + "summary": "Write Scene Prose", + "description": "Generate prose for one scene and automatically link generated boundaries.", + "operationId": "write_scene_prose_api_v1_projects__project_name__scenes__scene_id__write_post", + "parameters": [ + { + "name": "scene_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scene Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneWriteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SceneWriteResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/scenes/auto-link-scope": { + "post": { + "tags": ["Scenes"], + "summary": "Auto Link Saved Scope", + "description": "Auto-link a saved prose scope to the scenes that belong to it.", + "operationId": "auto_link_saved_scope_api_v1_projects__project_name__scenes_auto_link_scope_post", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoLinkScopeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoLinkScopeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/annotations": { + "get": { + "tags": ["Annotations"], + "summary": "Api List Annotations", + "description": "List all annotations for the project.", + "operationId": "api_list_annotations_api_v1_projects__project_name__annotations_get", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AnnotationResponse" + }, + "title": "Response Api List Annotations Api V1 Projects Project Name Annotations Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": ["Annotations"], + "summary": "Api Create Annotation", + "description": "Create a new inline annotation.", + "operationId": "api_create_annotation_api_v1_projects__project_name__annotations_post", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAnnotationRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnnotationResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/annotations/scope": { + "get": { + "tags": ["Annotations"], + "summary": "Api List Annotations For Scope", + "description": "List annotations for a specific prose scope.", + "operationId": "api_list_annotations_for_scope_api_v1_projects__project_name__annotations_scope_get", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + }, + { + "name": "scope_type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "story", + "title": "Scope Type" + } + }, + { + "name": "chapter_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chapter Id" + } + }, + { + "name": "book_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Book Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AnnotationResponse" + }, + "title": "Response Api List Annotations For Scope Api V1 Projects Project Name Annotations Scope Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/annotations/{annotation_id}": { + "get": { + "tags": ["Annotations"], + "summary": "Api Get Annotation", + "description": "Fetch a single annotation by ID.", + "operationId": "api_get_annotation_api_v1_projects__project_name__annotations__annotation_id__get", + "parameters": [ + { + "name": "annotation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Annotation Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnnotationResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": ["Annotations"], + "summary": "Api Update Annotation", + "description": "Update an annotation's comment.", + "operationId": "api_update_annotation_api_v1_projects__project_name__annotations__annotation_id__put", + "parameters": [ + { + "name": "annotation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Annotation Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAnnotationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnnotationResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": ["Annotations"], + "summary": "Api Delete Annotation", + "description": "Delete an annotation and remove its inline markers.", + "operationId": "api_delete_annotation_api_v1_projects__project_name__annotations__annotation_id__delete", + "parameters": [ + { + "name": "annotation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Annotation Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/projects/{project_name}/view-state": { + "get": { + "tags": ["View State"], + "summary": "Get View State", + "description": "Return the saved view state for the project, with defaults for missing fields.", + "operationId": "get_view_state_api_v1_projects__project_name__view_state_get", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewStateResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": ["View State"], + "summary": "Put View State", + "description": "Persist view state for the project.", + "operationId": "put_view_state_api_v1_projects__project_name__view_state_put", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewStatePayload" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewStateResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "summary": "", + "operationId": "_lambda__api_v1_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/": { + "get": { + "summary": " Root Redirect", + "description": "Helper for redirect..", + "operationId": "_root_redirect__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AnnotationResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "comment": { + "type": "string", + "title": "Comment" + }, + "scope_type": { + "type": "string", + "title": "Scope Type" + }, + "chapter_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chapter Id" + }, + "book_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Book Id" + }, + "start_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Start Offset" + }, + "end_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "End Offset" + } + }, + "type": "object", + "required": ["id", "comment", "scope_type"], + "title": "AnnotationResponse", + "description": "A single annotation with runtime offsets attached." + }, + "AutoLinkScopeRequest": { + "properties": { + "scope_type": { + "type": "string", + "enum": ["story", "chapter", "unlinked"], + "title": "Scope Type", + "default": "chapter" + }, + "chapter_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chapter Id" + }, + "book_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Book Id" + }, + "scene_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Scene Ids" + }, + "start_offset": { + "type": "integer", + "title": "Start Offset", + "default": 0 + }, + "end_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "End Offset" + }, + "prose_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prose Text" + }, + "current_text": { + "type": "string", + "title": "Current Text" + } + }, + "type": "object", + "required": ["current_text"], + "title": "AutoLinkScopeRequest", + "description": "Body for auto-linking a saved prose scope to its scenes." + }, + "AutoLinkScopeResponse": { + "properties": { + "assignments": { + "items": { + "$ref": "#/components/schemas/SceneBoundaryAssignment" + }, + "type": "array", + "title": "Assignments" + }, + "scenes": { + "items": { + "$ref": "#/components/schemas/Scene" + }, + "type": "array", + "title": "Scenes" + } + }, + "type": "object", + "title": "AutoLinkScopeResponse", + "description": "Response for auto-linking a saved prose scope to its scenes." + }, + "Body_api_projects_images_upload_api_v1_projects_images_upload_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": ["file"], + "title": "Body_api_projects_images_upload_api_v1_projects_images_upload_post" + }, + "Body_api_projects_import_api_v1_projects_import_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": ["file"], + "title": "Body_api_projects_import_api_v1_projects_import_post" + }, + "BookCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": ["name"], + "title": "BookCreateRequest", + "description": "Represents the BookCreateRequest type." + }, + "BookDeleteRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": ["name"], + "title": "BookDeleteRequest", + "description": "Represents the BookDeleteRequest type." + }, + "BookMutationResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "book_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Book Id" + }, + "restore_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Restore Id" + }, + "story": { + "anyOf": [ + { + "$ref": "#/components/schemas/StoryPayload" + }, + { + "type": "null" + } + ] + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + } + }, + "type": "object", + "required": ["ok"], + "title": "BookMutationResponse", + "description": "Response body for book create/delete/restore endpoints." + }, + "BookRestoreRequest": { + "properties": { + "restore_id": { + "type": "string", + "title": "Restore Id" + } + }, + "type": "object", + "required": ["restore_id"], + "title": "BookRestoreRequest", + "description": "Represents the BookRestoreRequest type." + }, + "BooksReorderRequest": { + "properties": { + "book_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Book Ids" + } + }, + "additionalProperties": false, + "type": "object", + "required": ["book_ids"], + "title": "BooksReorderRequest", + "description": "Request body for reordering books." + }, + "ChapterBeforeContentResponse": { + "properties": { + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": ["content"], + "title": "ChapterBeforeContentResponse", + "description": "Response body for ``GET /api/v1/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}``." + }, + "ChapterContentUpdate": { + "properties": { + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": ["content"], + "title": "ChapterContentUpdate", + "description": "Request body for updating chapter content." + }, + "ChapterCreate": { + "properties": { + "title": { + "type": "string", + "title": "Title" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content", + "default": "" + }, + "book_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Book Id" + } + }, + "type": "object", + "required": ["title"], + "title": "ChapterCreate", + "description": "Request body for creating a new chapter." + }, + "ChapterDetailResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "content": { + "type": "string", + "title": "Content" + }, + "summary": { + "type": "string", + "title": "Summary" + }, + "notes": { + "type": "string", + "title": "Notes" + }, + "private_notes": { + "type": "string", + "title": "Private Notes" + }, + "conflicts": { + "items": {}, + "type": "array", + "title": "Conflicts" + } + }, + "type": "object", + "required": [ + "id", + "title", + "filename", + "content", + "summary", + "notes", + "private_notes", + "conflicts" + ], + "title": "ChapterDetailResponse", + "description": "Response body for ``GET /api/v1/chapters/{chap_id}``." + }, + "ChapterMetadataUpdate": { + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Summary" + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + }, + "private_notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Private Notes" + }, + "conflicts": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Conflicts" + } + }, + "type": "object", + "title": "ChapterMetadataUpdate", + "description": "Request body for updating chapter metadata." + }, + "ChapterSummary": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "summary": { + "type": "string", + "title": "Summary" + }, + "notes": { + "type": "string", + "title": "Notes" + }, + "private_notes": { + "type": "string", + "title": "Private Notes" + }, + "conflicts": { + "items": {}, + "type": "array", + "title": "Conflicts" + }, + "book_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Book Id" + } + }, + "type": "object", + "required": [ + "id", + "title", + "filename", + "summary", + "notes", + "private_notes", + "conflicts" + ], + "title": "ChapterSummary", + "description": "Lightweight chapter descriptor used in list responses." + }, + "ChapterSummaryUpdate": { + "properties": { + "summary": { + "type": "string", + "title": "Summary" + } + }, + "type": "object", + "required": ["summary"], + "title": "ChapterSummaryUpdate", + "description": "Request body for updating chapter summary." + }, + "ChapterTitleUpdate": { + "properties": { + "title": { + "type": "string", + "title": "Title" + } + }, + "type": "object", + "required": ["title"], + "title": "ChapterTitleUpdate", + "description": "Request body for updating chapter title." + }, + "ChaptersListResponse": { + "properties": { + "chapters": { + "items": { + "$ref": "#/components/schemas/ChapterSummary" + }, + "type": "array", + "title": "Chapters" + } + }, + "type": "object", + "required": ["chapters"], + "title": "ChaptersListResponse", + "description": "Response body for ``GET /api/v1/chapters``." + }, + "ChaptersReorderRequest": { + "properties": { + "chapter_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Chapter Ids" + }, + "book_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Book Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": ["chapter_ids"], + "title": "ChaptersReorderRequest", + "description": "Request body for reordering chapters." + }, + "ChatDetailResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "messages": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Messages" + }, + "systemPrompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Systemprompt" + }, + "allowWebSearch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Allowwebsearch" + }, + "scratchpad": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scratchpad" + }, + "editing_scratchpad": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Editing Scratchpad" + }, + "projectContextRevision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Projectcontextrevision" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": ["id"], + "title": "ChatDetailResponse", + "description": "Response body for ``GET /api/v1/chats/{chat_id}``." + }, + "ChatInitialStateResponse": { + "properties": { + "models": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Models" + }, + "current_model": { + "type": "string", + "title": "Current Model" + }, + "messages": { + "items": {}, + "type": "array", + "title": "Messages" + } + }, + "type": "object", + "required": ["models", "current_model", "messages"], + "title": "ChatInitialStateResponse", + "description": "Response body for ``GET /api/v1/chat``.\n\nReturns the available LLM models and initial (empty) message history so\nthe frontend can initialise the chat panel without a separate request." + }, + "ChatListItem": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": ["id"], + "title": "ChatListItem", + "description": "Summary of a saved chat session returned in the chat list." + }, + "ChatListResponse": { + "properties": { + "chats": { + "items": { + "$ref": "#/components/schemas/ChatListItem" + }, + "type": "array", + "title": "Chats" + } + }, + "type": "object", + "required": ["chats"], + "title": "ChatListResponse", + "description": "Response body for ``GET /api/v1/chats`` (list of saved chats)." + }, + "ChatToolBatchMutationResponse": { + "properties": { "ok": { "type": "boolean", "title": "Ok" }, - "message": { + "batch_id": { "anyOf": [ { "type": "string" @@ -3611,7 +5557,82 @@ "type": "null" } ], - "title": "Message" + "title": "Batch Id" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + } + }, + "type": "object", + "required": ["ok"], + "title": "ChatToolBatchMutationResponse", + "description": "Response body for ``POST /api/v1/chat/tools/undo/{batch_id}``\nand ``POST /api/v1/chat/tools/redo/{batch_id}``." + }, + "CheckpointInfo": { + "properties": { + "timestamp": { + "type": "string", + "title": "Timestamp" + } + }, + "type": "object", + "required": ["timestamp"], + "title": "CheckpointInfo", + "description": "Represents the CheckpointInfo type." + }, + "CheckpointListResponse": { + "properties": { + "checkpoints": { + "items": { + "$ref": "#/components/schemas/CheckpointInfo" + }, + "type": "array", + "title": "Checkpoints" + } + }, + "type": "object", + "required": ["checkpoints"], + "title": "CheckpointListResponse", + "description": "Represents the CheckpointListResponse type." + }, + "CheckpointLoadDeleteRequest": { + "properties": { + "timestamp": { + "type": "string", + "title": "Timestamp" + } + }, + "type": "object", + "required": ["timestamp"], + "title": "CheckpointLoadDeleteRequest", + "description": "Represents the CheckpointLoadDeleteRequest type." + }, + "CreateAnnotationRequest": { + "properties": { + "scope_type": { + "type": "string", + "title": "Scope Type", + "description": "'story', 'chapter', or 'unlinked'" + }, + "chapter_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chapter Id", + "description": "Chapter ID for chapter scope" }, "book_id": { "anyOf": [ @@ -3622,9 +5643,171 @@ "type": "null" } ], - "title": "Book Id" + "title": "Book Id", + "description": "Book ID for nested chapter scope" }, - "restore_id": { + "start_offset": { + "type": "integer", + "title": "Start Offset", + "description": "Raw character offset (markers included)" + }, + "end_offset": { + "type": "integer", + "title": "End Offset", + "description": "Raw character offset (markers included)" + }, + "comment": { + "type": "string", + "title": "Comment", + "description": "Human-readable annotation comment" + } + }, + "type": "object", + "required": ["scope_type", "start_offset", "end_offset", "comment"], + "title": "CreateAnnotationRequest", + "description": "Payload for creating a new annotation." + }, + "DebugLogEntry": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "caller_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Caller Id" + }, + "model_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Type" + }, + "timestamp_start": { + "type": "string", + "title": "Timestamp Start" + }, + "timestamp_end": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timestamp End" + }, + "request": { + "$ref": "#/components/schemas/LLMLogRequest" + }, + "response": { + "anyOf": [ + { + "$ref": "#/components/schemas/LLMLogResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": ["id", "timestamp_start", "request"], + "title": "DebugLogEntry", + "description": "A single LLM communication log entry." + }, + "DebugLogsResponse": { + "properties": { + "logs": { + "items": { + "$ref": "#/components/schemas/DebugLogEntry" + }, + "type": "array", + "title": "Logs" + } + }, + "type": "object", + "required": ["logs"], + "title": "DebugLogsResponse", + "description": "Response body for ``GET /api/v1/debug/llm_logs``." + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ImageDeleteRequest": { + "properties": { + "filename": { + "type": "string", + "title": "Filename" + } + }, + "type": "object", + "required": ["filename"], + "title": "ImageDeleteRequest", + "description": "Represents the ImageDeleteRequest type." + }, + "ImageDescriptionUpdateRequest": { + "properties": { + "filename": { + "type": "string", + "title": "Filename" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "default": "" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title", + "default": "" + } + }, + "type": "object", + "required": ["filename"], + "title": "ImageDescriptionUpdateRequest", + "description": "Represents the ImageDescriptionUpdateRequest type." + }, + "ImagePlaceholderRequest": { + "properties": { + "name": { "anyOf": [ { "type": "string" @@ -3633,19 +5816,22 @@ "type": "null" } ], - "title": "Restore Id" + "title": "Name", + "default": "" }, - "story": { + "title": { "anyOf": [ { - "$ref": "#/components/schemas/StoryPayload" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Title", + "default": "" }, - "detail": { + "description": { "anyOf": [ { "type": "string" @@ -3654,15 +5840,15 @@ "type": "null" } ], - "title": "Detail" + "title": "Description", + "default": "" } }, "type": "object", - "required": ["ok"], - "title": "BookMutationResponse", - "description": "Response body for book create/delete/restore endpoints." + "title": "ImagePlaceholderRequest", + "description": "Represents the ImagePlaceholderRequest type." }, - "BookRestoreRequest": { + "ImageRestoreRequest": { "properties": { "restore_id": { "type": "string", @@ -3671,55 +5857,137 @@ }, "type": "object", "required": ["restore_id"], - "title": "BookRestoreRequest", - "description": "Represents the BookRestoreRequest type." + "title": "ImageRestoreRequest", + "description": "Represents the ImageRestoreRequest type." }, - "BooksReorderRequest": { + "LLMLogRequest": { "properties": { - "book_ids": { - "items": { + "url": { + "type": "string", + "title": "Url" + }, + "method": { + "type": "string", + "title": "Method" + }, + "headers": { + "additionalProperties": { "type": "string" }, - "type": "array", - "title": "Book Ids" + "type": "object", + "title": "Headers" + }, + "body": { + "title": "Body" } }, "type": "object", - "required": ["book_ids"], - "title": "BooksReorderRequest", - "description": "Request body for reordering books." + "required": ["url", "method", "headers", "body"], + "title": "LLMLogRequest", + "description": "Shape of the request portion of an LLM log entry." }, - "ChapterBeforeContentResponse": { + "LLMLogResponse": { "properties": { - "content": { - "type": "string", - "title": "Content" + "status_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Status Code" + }, + "body": { + "title": "Body" + }, + "streaming": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Streaming" + }, + "chunks": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Chunks" + }, + "full_content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Full Content" + }, + "thinking": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thinking" + }, + "error": { + "title": "Error" + }, + "error_detail": { + "title": "Error Detail" + }, + "tool_calls": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tool Calls" } }, "type": "object", - "required": ["content"], - "title": "ChapterBeforeContentResponse", - "description": "Response body for ``GET /api/v1/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}``." + "title": "LLMLogResponse", + "description": "Shape of the response portion of an LLM log entry." }, - "ChapterContentUpdate": { + "ListImagesResponse": { "properties": { - "content": { - "type": "string", - "title": "Content" + "images": { + "items": { + "$ref": "#/components/schemas/ProjectImageInfo" + }, + "type": "array", + "title": "Images" } }, "type": "object", - "required": ["content"], - "title": "ChapterContentUpdate", - "description": "Request body for updating chapter content." + "required": ["images"], + "title": "ListImagesResponse", + "description": "Response body for ``GET /api/v1/projects/images/list``." }, - "ChapterCreate": { + "MachineConfigResponse": { "properties": { - "title": { - "type": "string", - "title": "Title" - }, - "content": { + "gui_language": { "anyOf": [ { "type": "string" @@ -3728,171 +5996,173 @@ "type": "null" } ], - "title": "Content", - "default": "" + "title": "Gui Language" }, - "book_id": { + "openai": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/MachineOpenAIConfig" }, { "type": "null" } - ], - "title": "Book Id" + ] } }, "type": "object", - "required": ["title"], - "title": "ChapterCreate", - "description": "Request body for creating a new chapter." + "title": "MachineConfigResponse", + "description": "Response body for ``GET /api/v1/machine``." }, - "ChapterDetailResponse": { + "MachineModelConfig": { "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "filename": { + "name": { "type": "string", - "title": "Filename" + "title": "Name" }, - "content": { + "base_url": { "type": "string", - "title": "Content" + "title": "Base Url" }, - "summary": { - "type": "string", - "title": "Summary" + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" }, - "notes": { + "model": { "type": "string", - "title": "Notes" + "title": "Model" }, - "private_notes": { - "type": "string", - "title": "Private Notes" + "timeout_s": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timeout S" }, - "conflicts": { - "items": {}, - "type": "array", - "title": "Conflicts" - } - }, - "type": "object", - "required": [ - "id", - "title", - "filename", - "content", - "summary", - "notes", - "private_notes", - "conflicts" - ], - "title": "ChapterDetailResponse", - "description": "Response body for ``GET /api/v1/chapters/{chap_id}``." - }, - "ChapterMetadataUpdate": { - "properties": { - "title": { + "context_window_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Context Window Tokens" + }, + "temperature": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Title" + "title": "Temperature" }, - "summary": { + "top_p": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Summary" + "title": "Top P" }, - "notes": { + "max_tokens": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Notes" + "title": "Max Tokens" }, - "private_notes": { + "presence_penalty": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Private Notes" + "title": "Presence Penalty" }, - "conflicts": { + "frequency_penalty": { "anyOf": [ { - "items": {}, - "type": "array" + "type": "number" }, { "type": "null" } ], - "title": "Conflicts" - } - }, - "type": "object", - "title": "ChapterMetadataUpdate", - "description": "Request body for updating chapter metadata." - }, - "ChapterSummary": { - "properties": { - "id": { - "type": "integer", - "title": "Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "filename": { - "type": "string", - "title": "Filename" + "title": "Frequency Penalty" }, - "summary": { - "type": "string", - "title": "Summary" + "stop": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Stop" }, - "notes": { - "type": "string", - "title": "Notes" + "seed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Seed" }, - "private_notes": { - "type": "string", - "title": "Private Notes" + "top_k": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Top K" }, - "conflicts": { - "items": {}, - "type": "array", - "title": "Conflicts" + "min_p": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Min P" }, - "book_id": { + "extra_body": { "anyOf": [ { "type": "string" @@ -3901,71 +6171,9 @@ "type": "null" } ], - "title": "Book Id" - } - }, - "type": "object", - "required": [ - "id", - "title", - "filename", - "summary", - "notes", - "private_notes", - "conflicts" - ], - "title": "ChapterSummary", - "description": "Lightweight chapter descriptor used in list responses." - }, - "ChapterSummaryUpdate": { - "properties": { - "summary": { - "type": "string", - "title": "Summary" - } - }, - "type": "object", - "required": ["summary"], - "title": "ChapterSummaryUpdate", - "description": "Request body for updating chapter summary." - }, - "ChapterTitleUpdate": { - "properties": { - "title": { - "type": "string", - "title": "Title" - } - }, - "type": "object", - "required": ["title"], - "title": "ChapterTitleUpdate", - "description": "Request body for updating chapter title." - }, - "ChaptersListResponse": { - "properties": { - "chapters": { - "items": { - "$ref": "#/components/schemas/ChapterSummary" - }, - "type": "array", - "title": "Chapters" - } - }, - "type": "object", - "required": ["chapters"], - "title": "ChaptersListResponse", - "description": "Response body for ``GET /api/v1/chapters``." - }, - "ChaptersReorderRequest": { - "properties": { - "chapter_ids": { - "items": { - "type": "integer" - }, - "type": "array", - "title": "Chapter Ids" + "title": "Extra Body" }, - "book_id": { + "preset_id": { "anyOf": [ { "type": "string" @@ -3974,21 +6182,9 @@ "type": "null" } ], - "title": "Book Id" - } - }, - "type": "object", - "required": ["chapter_ids"], - "title": "ChaptersReorderRequest", - "description": "Request body for reordering chapters." - }, - "ChatDetailResponse": { - "properties": { - "id": { - "type": "string", - "title": "Id" + "title": "Preset Id" }, - "name": { + "writing_warning": { "anyOf": [ { "type": "string" @@ -3997,32 +6193,31 @@ "type": "null" } ], - "title": "Name" + "title": "Writing Warning" }, - "messages": { + "is_multimodal": { "anyOf": [ { - "items": {}, - "type": "array" + "type": "boolean" }, { "type": "null" } ], - "title": "Messages" + "title": "Is Multimodal" }, - "systemPrompt": { + "supports_function_calling": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "null" } ], - "title": "Systemprompt" + "title": "Supports Function Calling" }, - "allowWebSearch": { + "suggest_loop_guard_enabled": { "anyOf": [ { "type": "boolean" @@ -4031,31 +6226,31 @@ "type": "null" } ], - "title": "Allowwebsearch" + "title": "Suggest Loop Guard Enabled" }, - "scratchpad": { + "suggest_loop_guard_ngram": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Scratchpad" + "title": "Suggest Loop Guard Ngram" }, - "editing_scratchpad": { + "suggest_loop_guard_min_repeats": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Editing Scratchpad" + "title": "Suggest Loop Guard Min Repeats" }, - "projectContextRevision": { + "suggest_loop_guard_max_regens": { "anyOf": [ { "type": "integer" @@ -4064,20 +6259,45 @@ "type": "null" } ], - "title": "Projectcontextrevision" + "title": "Suggest Loop Guard Max Regens" }, - "created_at": { + "prompt_overrides": { "anyOf": [ { - "type": "string" + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Prompt Overrides" + } + }, + "type": "object", + "required": ["name", "base_url", "model"], + "title": "MachineModelConfig", + "description": "Configuration for a single LLM provider / model entry." + }, + "MachineOpenAIConfig": { + "properties": { + "models": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MachineModelConfig" + }, + "type": "array" }, { "type": "null" } ], - "title": "Created At" + "title": "Models" }, - "updated_at": { + "selected": { "anyOf": [ { "type": "string" @@ -4086,45 +6306,9 @@ "type": "null" } ], - "title": "Updated At" - } - }, - "type": "object", - "required": ["id"], - "title": "ChatDetailResponse", - "description": "Response body for ``GET /api/v1/chats/{chat_id}``." - }, - "ChatInitialStateResponse": { - "properties": { - "models": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Models" - }, - "current_model": { - "type": "string", - "title": "Current Model" - }, - "messages": { - "items": {}, - "type": "array", - "title": "Messages" - } - }, - "type": "object", - "required": ["models", "current_model", "messages"], - "title": "ChatInitialStateResponse", - "description": "Response body for ``GET /api/v1/chat``.\n\nReturns the available LLM models and initial (empty) message history so\nthe frontend can initialise the chat panel without a separate request." - }, - "ChatListItem": { - "properties": { - "id": { - "type": "string", - "title": "Id" + "title": "Selected" }, - "name": { + "selected_chat": { "anyOf": [ { "type": "string" @@ -4133,9 +6317,9 @@ "type": "null" } ], - "title": "Name" + "title": "Selected Chat" }, - "created_at": { + "selected_writing": { "anyOf": [ { "type": "string" @@ -4144,9 +6328,9 @@ "type": "null" } ], - "title": "Created At" + "title": "Selected Writing" }, - "updated_at": { + "selected_editing": { "anyOf": [ { "type": "string" @@ -4155,36 +6339,47 @@ "type": "null" } ], - "title": "Updated At" + "title": "Selected Editing" } }, "type": "object", - "required": ["id"], - "title": "ChatListItem", - "description": "Summary of a saved chat session returned in the chat list." + "title": "MachineOpenAIConfig", + "description": "The ``openai`` section of the machine config." }, - "ChatListResponse": { + "MachinePresetsResponse": { "properties": { - "chats": { + "presets": { "items": { - "$ref": "#/components/schemas/ChatListItem" + "$ref": "#/components/schemas/ModelPresetEntry" }, "type": "array", - "title": "Chats" + "title": "Presets" } }, "type": "object", - "required": ["chats"], - "title": "ChatListResponse", - "description": "Response body for ``GET /api/v1/chats`` (list of saved chats)." + "required": ["presets"], + "title": "MachinePresetsResponse", + "description": "Response body for ``GET /api/v1/machine/presets``." }, - "ChatToolBatchMutationResponse": { + "MachineTestModelResponse": { "properties": { "ok": { "type": "boolean", "title": "Ok" }, - "batch_id": { + "model_ok": { + "type": "boolean", + "title": "Model Ok" + }, + "models": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Models", + "default": [] + }, + "detail": { "anyOf": [ { "type": "string" @@ -4193,71 +6388,39 @@ "type": "null" } ], - "title": "Batch Id" + "title": "Detail" }, - "detail": { + "capabilities": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/ModelCapabilities" }, { "type": "null" } - ], - "title": "Detail" - } - }, - "type": "object", - "required": ["ok"], - "title": "ChatToolBatchMutationResponse", - "description": "Response body for ``POST /api/v1/chat/tools/undo/{batch_id}``\nand ``POST /api/v1/chat/tools/redo/{batch_id}``." - }, - "CheckpointInfo": { - "properties": { - "timestamp": { - "type": "string", - "title": "Timestamp" + ] } }, "type": "object", - "required": ["timestamp"], - "title": "CheckpointInfo", - "description": "Represents the CheckpointInfo type." + "required": ["ok", "model_ok"], + "title": "MachineTestModelResponse", + "description": "Response body for ``POST /api/v1/machine/test_model``." }, - "CheckpointListResponse": { + "MachineTestResponse": { "properties": { - "checkpoints": { + "ok": { + "type": "boolean", + "title": "Ok" + }, + "models": { "items": { - "$ref": "#/components/schemas/CheckpointInfo" + "type": "string" }, "type": "array", - "title": "Checkpoints" - } - }, - "type": "object", - "required": ["checkpoints"], - "title": "CheckpointListResponse", - "description": "Represents the CheckpointListResponse type." - }, - "CheckpointLoadDeleteRequest": { - "properties": { - "timestamp": { - "type": "string", - "title": "Timestamp" - } - }, - "type": "object", - "required": ["timestamp"], - "title": "CheckpointLoadDeleteRequest", - "description": "Represents the CheckpointLoadDeleteRequest type." - }, - "DebugLogEntry": { - "properties": { - "id": { - "type": "string", - "title": "Id" + "title": "Models", + "default": [] }, - "caller_id": { + "detail": { "anyOf": [ { "type": "string" @@ -4266,213 +6429,147 @@ "type": "null" } ], - "title": "Caller Id" - }, - "model_type": { + "title": "Detail" + } + }, + "type": "object", + "required": ["ok"], + "title": "MachineTestResponse", + "description": "Response body for ``POST /api/v1/machine/test``." + }, + "ModelCapabilities": { + "properties": { + "multimodal": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "null" } ], - "title": "Model Type" - }, - "timestamp_start": { - "type": "string", - "title": "Timestamp Start" + "title": "Multimodal" }, - "timestamp_end": { + "function_calling": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "null" } ], - "title": "Timestamp End" - }, - "request": { - "$ref": "#/components/schemas/LLMLogRequest" - }, - "response": { - "anyOf": [ - { - "$ref": "#/components/schemas/LLMLogResponse" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": ["id", "timestamp_start", "request"], - "title": "DebugLogEntry", - "description": "A single LLM communication log entry." - }, - "DebugLogsResponse": { - "properties": { - "logs": { - "items": { - "$ref": "#/components/schemas/DebugLogEntry" - }, - "type": "array", - "title": "Logs" + "title": "Function Calling" } }, "type": "object", - "required": ["logs"], - "title": "DebugLogsResponse", - "description": "Response body for ``GET /api/v1/debug/llm_logs``." + "title": "ModelCapabilities", + "description": "Subset of capabilities detected for a model (optional fields)." }, - "HTTPValidationError": { + "ModelPresetEntry": { "properties": { - "detail": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "model_id_patterns": { "items": { - "$ref": "#/components/schemas/ValidationError" + "type": "string" }, "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "ImageDeleteRequest": { - "properties": { - "filename": { + "title": "Model Id Patterns" + }, + "preset_type": { "type": "string", - "title": "Filename" + "enum": ["absolute", "delta"], + "title": "Preset Type", + "default": "absolute" + }, + "parameters": { + "$ref": "#/components/schemas/ModelPresetParameters" + }, + "warnings": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelPresetWarning" + }, + { + "type": "null" + } + ] } }, "type": "object", - "required": ["filename"], - "title": "ImageDeleteRequest", - "description": "Represents the ImageDeleteRequest type." + "required": ["id", "name", "description", "model_id_patterns", "parameters"], + "title": "ModelPresetEntry", + "description": "A single model-preset entry as loaded from *model_presets.json*.\n\n``preset_type`` distinguishes two flavours:\n\n* ``\"absolute\"`` (default) \u2013 replaces **all** sampling parameters on the\n provider and locks manual editing. Suitable for a named full profile\n tied to a specific model family.\n* ``\"delta\"`` \u2013 applies **only the non-null fields** in ``parameters`` on\n top of whatever is already configured. Does not lock the provider or\n change ``preset_id``. Suitable for cross-model tweaks such as \"more\n creative\" or \"factual focus\"." }, - "ImageDescriptionUpdateRequest": { + "ModelPresetParameters": { "properties": { - "filename": { - "type": "string", - "title": "Filename" - }, - "description": { + "temperature": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Description", - "default": "" + "title": "Temperature" }, - "title": { + "top_p": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Title", - "default": "" - } - }, - "type": "object", - "required": ["filename"], - "title": "ImageDescriptionUpdateRequest", - "description": "Represents the ImageDescriptionUpdateRequest type." - }, - "ImagePlaceholderRequest": { - "properties": { - "name": { + "title": "Top P" + }, + "max_tokens": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Name", - "default": "" + "title": "Max Tokens" }, - "title": { + "presence_penalty": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Title", - "default": "" + "title": "Presence Penalty" }, - "description": { + "frequency_penalty": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Description", - "default": "" - } - }, - "type": "object", - "title": "ImagePlaceholderRequest", - "description": "Represents the ImagePlaceholderRequest type." - }, - "ImageRestoreRequest": { - "properties": { - "restore_id": { - "type": "string", - "title": "Restore Id" - } - }, - "type": "object", - "required": ["restore_id"], - "title": "ImageRestoreRequest", - "description": "Represents the ImageRestoreRequest type." - }, - "LLMLogRequest": { - "properties": { - "url": { - "type": "string", - "title": "Url" - }, - "method": { - "type": "string", - "title": "Method" - }, - "headers": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Headers" + "title": "Frequency Penalty" }, - "body": { - "title": "Body" - } - }, - "type": "object", - "required": ["url", "method", "headers", "body"], - "title": "LLMLogRequest", - "description": "Shape of the request portion of an LLM log entry." - }, - "LLMLogResponse": { - "properties": { - "status_code": { + "seed": { "anyOf": [ { "type": "integer" @@ -4481,35 +6578,45 @@ "type": "null" } ], - "title": "Status Code" + "title": "Seed" }, - "body": { - "title": "Body" + "top_k": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Top K" }, - "streaming": { + "min_p": { "anyOf": [ { - "type": "boolean" + "type": "number" }, { "type": "null" } ], - "title": "Streaming" + "title": "Min P" }, - "chunks": { + "stop": { "anyOf": [ { - "items": {}, + "items": { + "type": "string" + }, "type": "array" }, { "type": "null" } ], - "title": "Chunks" + "title": "Stop" }, - "full_content": { + "extra_body": { "anyOf": [ { "type": "string" @@ -4518,9 +6625,38 @@ "type": "null" } ], - "title": "Full Content" + "title": "Extra Body" + } + }, + "type": "object", + "title": "ModelPresetParameters", + "description": "Typed parameters for a model preset." + }, + "ModelPresetWarning": { + "properties": { + "writing": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Writing" + } + }, + "type": "object", + "title": "ModelPresetWarning", + "description": "Warnings that should be surfaced to the user for a preset." + }, + "OkSelectedResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" }, - "thinking": { + "selected": { "anyOf": [ { "type": "string" @@ -4529,49 +6665,84 @@ "type": "null" } ], - "title": "Thinking" + "title": "Selected" }, - "error": { - "title": "Error" + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + } + }, + "type": "object", + "required": ["ok"], + "title": "OkSelectedResponse", + "description": "Response for ``PUT /api/v1/machine`` \u2013 returns the new selected model name." + }, + "ProjectConvertRequest": { + "properties": { + "target_type": { + "type": "string", + "title": "Target Type" + } + }, + "type": "object", + "required": ["target_type"], + "title": "ProjectConvertRequest", + "description": "Represents the ProjectConvertRequest type." + }, + "ProjectCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name" }, - "error_detail": { - "title": "Error Detail" + "type": { + "type": "string", + "title": "Type" }, - "tool_calls": { + "language": { "anyOf": [ { - "items": {}, - "type": "array" + "type": "string" }, { "type": "null" } ], - "title": "Tool Calls" + "title": "Language", + "default": "en" } }, "type": "object", - "title": "LLMLogResponse", - "description": "Shape of the response portion of an LLM log entry." + "required": ["name", "type"], + "title": "ProjectCreateRequest", + "description": "Represents the ProjectCreateRequest type." }, - "ListImagesResponse": { + "ProjectDeleteRequest": { "properties": { - "images": { - "items": { - "$ref": "#/components/schemas/ProjectImageInfo" - }, - "type": "array", - "title": "Images" + "name": { + "type": "string", + "title": "Name" } }, "type": "object", - "required": ["images"], - "title": "ListImagesResponse", - "description": "Response body for ``GET /api/v1/projects/images/list``." + "required": ["name"], + "title": "ProjectDeleteRequest", + "description": "Represents the ProjectDeleteRequest type." }, - "MachineConfigResponse": { + "ProjectImageInfo": { "properties": { - "gui_language": { + "filename": { + "type": "string", + "title": "Filename" + }, + "url": { "anyOf": [ { "type": "string" @@ -4580,130 +6751,203 @@ "type": "null" } ], - "title": "Gui Language" + "title": "Url" }, - "openai": { + "description": { "anyOf": [ { - "$ref": "#/components/schemas/MachineOpenAIConfig" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Description" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "is_placeholder": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Placeholder" } }, "type": "object", - "title": "MachineConfigResponse", - "description": "Response body for ``GET /api/v1/machine``." + "required": ["filename"], + "title": "ProjectImageInfo", + "description": "Describes a single project image or image placeholder." }, - "MachineModelConfig": { + "ProjectInfo": { "properties": { + "id": { + "type": "string", + "title": "Id" + }, "name": { "type": "string", "title": "Name" }, - "base_url": { + "path": { "type": "string", - "title": "Base Url" + "title": "Path" }, - "api_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Api Key" + "is_valid": { + "type": "boolean", + "title": "Is Valid" }, - "model": { + "title": { "type": "string", - "title": "Model" + "title": "Title" }, - "timeout_s": { + "type": { + "type": "string", + "title": "Type", + "default": "novel" + }, + "language": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Timeout S" + "title": "Language" + } + }, + "type": "object", + "required": ["id", "name", "path", "is_valid", "title"], + "title": "ProjectInfo", + "description": "Describes a single project entry returned by the listing endpoint." + }, + "ProjectListResponse": { + "properties": { + "current": { + "type": "string", + "title": "Current" }, - "context_window_tokens": { + "recent": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Recent" + }, + "available": { + "items": { + "$ref": "#/components/schemas/ProjectInfo" + }, + "type": "array", + "title": "Available" + } + }, + "type": "object", + "required": ["current", "recent", "available"], + "title": "ProjectListResponse", + "description": "Response body for ``GET /api/v1/projects``." + }, + "ProjectMutationResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" + }, + "message": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Context Window Tokens" + "title": "Message" }, - "temperature": { + "detail": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Temperature" + "title": "Detail" }, - "top_p": { + "registry": { "anyOf": [ { - "type": "number" + "$ref": "#/components/schemas/ProjectRegistry" }, { "type": "null" } - ], - "title": "Top P" + ] }, - "max_tokens": { + "story": { "anyOf": [ { - "type": "integer" + "$ref": "#/components/schemas/StoryPayload" }, { "type": "null" } - ], - "title": "Max Tokens" - }, - "presence_penalty": { + ] + } + }, + "type": "object", + "required": ["ok"], + "title": "ProjectMutationResponse", + "description": "Generic response for project create/delete/convert operations." + }, + "ProjectRegistry": { + "properties": { + "current": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Presence Penalty" + "title": "Current" }, - "frequency_penalty": { + "recent": { "anyOf": [ { - "type": "number" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], - "title": "Frequency Penalty" + "title": "Recent" }, - "stop": { + "available": { "anyOf": [ { "items": { - "type": "string" + "$ref": "#/components/schemas/ProjectRegistryEntry" }, "type": "array" }, @@ -4711,42 +6955,84 @@ "type": "null" } ], - "title": "Stop" + "title": "Available" + } + }, + "type": "object", + "title": "ProjectRegistry", + "description": "Registry summary returned alongside project mutations." + }, + "ProjectRegistryEntry": { + "properties": { + "name": { + "type": "string", + "title": "Name" }, - "seed": { + "path": { + "type": "string", + "title": "Path" + }, + "is_valid": { + "type": "boolean", + "title": "Is Valid", + "default": true + } + }, + "type": "object", + "required": ["name", "path"], + "title": "ProjectRegistryEntry", + "description": "A single project registry entry embedded in mutation responses." + }, + "ProjectSelectRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": ["name"], + "title": "ProjectSelectRequest", + "description": "Represents the ProjectSelectRequest type." + }, + "ProjectSelectResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" + }, + "message": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Seed" + "title": "Message" }, - "top_k": { + "registry": { "anyOf": [ { - "type": "integer" + "$ref": "#/components/schemas/ProjectRegistry" }, { "type": "null" } - ], - "title": "Top K" + ] }, - "min_p": { + "story": { "anyOf": [ { - "type": "number" + "$ref": "#/components/schemas/StoryPayload" }, { "type": "null" } - ], - "title": "Min P" + ] }, - "extra_body": { + "error": { "anyOf": [ { "type": "string" @@ -4755,9 +7041,9 @@ "type": "null" } ], - "title": "Extra Body" + "title": "Error" }, - "preset_id": { + "error_message": { "anyOf": [ { "type": "string" @@ -4766,53 +7052,111 @@ "type": "null" } ], - "title": "Preset Id" + "title": "Error Message" + } + }, + "type": "object", + "required": ["ok"], + "title": "ProjectSelectResponse", + "description": "Response body for ``POST /api/v1/projects/select``." + }, + "PromptsResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" }, - "writing_warning": { + "system_messages": { "anyOf": [ { - "type": "string" + "additionalProperties": { + "type": "string" + }, + "type": "object" }, { "type": "null" } ], - "title": "Writing Warning" + "title": "System Messages" }, - "is_multimodal": { + "user_prompts": { "anyOf": [ { - "type": "boolean" + "additionalProperties": { + "type": "string" + }, + "type": "object" }, { "type": "null" } ], - "title": "Is Multimodal" + "title": "User Prompts" }, - "supports_function_calling": { + "languages": { "anyOf": [ { - "type": "boolean" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], - "title": "Supports Function Calling" + "title": "Languages" }, - "suggest_loop_guard_enabled": { + "project_language": { "anyOf": [ { - "type": "boolean" + "type": "string" }, { "type": "null" } ], - "title": "Suggest Loop Guard Enabled" + "title": "Project Language" + } + }, + "type": "object", + "required": ["ok"], + "title": "PromptsResponse", + "description": "Response for ``GET /api/v1/prompts``." + }, + "ReplaceAllRequest": { + "properties": { + "query": { + "type": "string", + "title": "Query", + "description": "Text or pattern to search for" }, - "suggest_loop_guard_ngram": { + "replacement": { + "type": "string", + "title": "Replacement", + "description": "Text to substitute in place of each match" + }, + "scope": { + "$ref": "#/components/schemas/SearchScope", + "default": "all" + }, + "case_sensitive": { + "type": "boolean", + "title": "Case Sensitive", + "default": false + }, + "is_regex": { + "type": "boolean", + "title": "Is Regex", + "default": false + }, + "is_phonetic": { + "type": "boolean", + "title": "Is Phonetic", + "default": false + }, + "active_chapter_id": { "anyOf": [ { "type": "integer" @@ -4821,67 +7165,218 @@ "type": "null" } ], - "title": "Suggest Loop Guard Ngram" + "title": "Active Chapter Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": ["query", "replacement"], + "title": "ReplaceAllRequest", + "description": "Request to replace all occurrences of a search query." + }, + "ReplaceChangeLocation": { + "properties": { + "type": { + "type": "string", + "title": "Type", + "description": "One of: chapter, story, metadata, sourcebook, book" }, - "suggest_loop_guard_min_repeats": { + "target_id": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Suggest Loop Guard Min Repeats" + "title": "Target Id", + "description": "Target identifier for the changed section, e.g. chapter ID or sourcebook entry name" }, - "suggest_loop_guard_max_regens": { + "field": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Suggest Loop Guard Max Regens" + "title": "Field", + "description": "Optional field name or metadata subfield affected by the replacement" }, - "prompt_overrides": { + "label": { + "type": "string", + "title": "Label", + "description": "Human-readable label for the changed section" + } + }, + "type": "object", + "required": ["type", "label"], + "title": "ReplaceChangeLocation", + "description": "Structured information about a single replaced section." + }, + "ReplaceResponse": { + "properties": { + "replacements_made": { + "type": "integer", + "title": "Replacements Made", + "description": "Total number of occurrences replaced", + "default": 0 + }, + "changed_sections": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Changed Sections", + "description": "Human-readable labels for each changed section" + }, + "changed_sections_meta": { + "items": { + "$ref": "#/components/schemas/ReplaceChangeLocation" + }, + "type": "array", + "title": "Changed Sections Meta", + "description": "Structured information for each changed section" + } + }, + "type": "object", + "title": "ReplaceResponse", + "description": "Result of a replace operation." + }, + "ReplaceSingleRequest": { + "properties": { + "query": { + "type": "string", + "title": "Query", + "description": "Text or pattern to search for" + }, + "replacement": { + "type": "string", + "title": "Replacement", + "description": "Text to substitute in place of the match" + }, + "scope": { + "$ref": "#/components/schemas/SearchScope", + "default": "all" + }, + "case_sensitive": { + "type": "boolean", + "title": "Case Sensitive", + "default": false + }, + "is_regex": { + "type": "boolean", + "title": "Is Regex", + "default": false + }, + "is_phonetic": { + "type": "boolean", + "title": "Is Phonetic", + "default": false + }, + "active_chapter_id": { "anyOf": [ { - "additionalProperties": { - "type": "string" - }, - "type": "object" + "type": "integer" }, { "type": "null" } ], - "title": "Prompt Overrides" + "title": "Active Chapter Id" + }, + "section_type": { + "type": "string", + "title": "Section Type", + "description": "The section_type value from SearchResultSection" + }, + "section_id": { + "type": "string", + "title": "Section Id", + "description": "The section_id value from SearchResultSection" + }, + "field": { + "type": "string", + "title": "Field", + "description": "The field value from SearchResultSection" + }, + "match_index": { + "type": "integer", + "minimum": 0.0, + "title": "Match Index", + "description": "Zero-based index of the match within this section+field" } }, + "additionalProperties": false, "type": "object", - "required": ["name", "base_url", "model"], - "title": "MachineModelConfig", - "description": "Configuration for a single LLM provider / model entry." + "required": [ + "query", + "replacement", + "section_type", + "section_id", + "field", + "match_index" + ], + "title": "ReplaceSingleRequest", + "description": "Request to replace one specific match (identified by its ordinal index)." }, - "MachineOpenAIConfig": { + "Scene": { "properties": { - "models": { + "id": { + "type": "integer", + "title": "Id" + }, + "summary": { + "type": "string", + "title": "Summary", + "default": "" + }, + "beats": { + "items": { + "$ref": "#/components/schemas/SceneBeat" + }, + "type": "array", + "title": "Beats", + "default": [] + }, + "active_characters": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Active Characters", + "default": [] + }, + "passive_characters": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Passive Characters", + "default": [] + }, + "sourcebook_entry_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Sourcebook Entry Ids", + "default": [] + }, + "location": { "anyOf": [ { - "items": { - "$ref": "#/components/schemas/MachineModelConfig" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], - "title": "Models" + "title": "Location" }, - "selected": { + "time": { "anyOf": [ { "type": "string" @@ -4890,20 +7385,24 @@ "type": "null" } ], - "title": "Selected" + "title": "Time" }, - "selected_chat": { + "scene_time": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/SceneChronologyTime" }, { "type": "null" } - ], - "title": "Selected Chat" + ] }, - "selected_writing": { + "timeline_id": { + "type": "string", + "title": "Timeline Id", + "default": "main" + }, + "color_tag": { "anyOf": [ { "type": "string" @@ -4912,58 +7411,82 @@ "type": "null" } ], - "title": "Selected Writing" + "title": "Color Tag" }, - "selected_editing": { + "prose_link": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/SceneProseLink" }, { "type": "null" } - ], - "title": "Selected Editing" - } - }, - "type": "object", - "title": "MachineOpenAIConfig", - "description": "The ``openai`` section of the machine config." - }, - "MachinePresetsResponse": { - "properties": { - "presets": { + ] + }, + "causes": { "items": { - "$ref": "#/components/schemas/ModelPresetEntry" + "type": "integer" }, "type": "array", - "title": "Presets" + "title": "Causes", + "default": [] + }, + "pinboard_x": { + "type": "number", + "title": "Pinboard X", + "default": 100.0 + }, + "pinboard_y": { + "type": "number", + "title": "Pinboard Y", + "default": 100.0 + }, + "status": { + "type": "string", + "title": "Status", + "default": "active" + }, + "tag_personal_datetimes": { + "items": { + "$ref": "#/components/schemas/SceneTagPersonalDatetime" + }, + "type": "array", + "title": "Tag Personal Datetimes" + }, + "annotations": { + "items": { + "$ref": "#/components/schemas/SceneAnnotation" + }, + "type": "array", + "title": "Annotations", + "description": "Optional inline annotations associated with this scene." } }, "type": "object", - "required": ["presets"], - "title": "MachinePresetsResponse", - "description": "Response body for ``GET /api/v1/machine/presets``." + "required": ["id"], + "title": "Scene", + "description": "A narrative scene used for structural story planning.\n\nActive characters, passive characters, location, and time are stored as\nsourcebook entry IDs so the frontend can look them up by reference.\n\n``causes`` stores IDs of other scenes that this scene causally precedes.\nThese are not narrative order indexes; they are dependency constraints that\nallow the pinboard to highlight cause/effect relationships.\n\n``pinboard_x`` / ``pinboard_y`` store the card's free-form position on the\npinboard canvas, in logical (unscaled) units." }, - "MachineTestModelResponse": { + "SceneAnnotation": { "properties": { - "ok": { - "type": "boolean", - "title": "Ok" + "id": { + "type": "string", + "title": "Id", + "description": "Stable annotation identifier." }, - "model_ok": { - "type": "boolean", - "title": "Model Ok" + "comment": { + "type": "string", + "title": "Comment", + "description": "Human-readable annotation comment." }, - "models": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Models", - "default": [] + "scope_type": { + "type": "string", + "enum": ["story", "chapter", "unlinked"], + "title": "Scope Type", + "description": "Scope where the annotation lives.", + "default": "story" }, - "detail": { + "chapter_id": { "anyOf": [ { "type": "string" @@ -4972,39 +7495,10 @@ "type": "null" } ], - "title": "Detail" - }, - "capabilities": { - "anyOf": [ - { - "$ref": "#/components/schemas/ModelCapabilities" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": ["ok", "model_ok"], - "title": "MachineTestModelResponse", - "description": "Response body for ``POST /api/v1/machine/test_model``." - }, - "MachineTestResponse": { - "properties": { - "ok": { - "type": "boolean", - "title": "Ok" - }, - "models": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Models", - "default": [] + "title": "Chapter Id", + "description": "Chapter ID when scope_type='chapter'." }, - "detail": { + "book_id": { "anyOf": [ { "type": "string" @@ -5013,194 +7507,294 @@ "type": "null" } ], - "title": "Detail" + "title": "Book Id", + "description": "Book ID when scope_type='chapter' and the chapter is nested." } }, + "additionalProperties": false, "type": "object", - "required": ["ok"], - "title": "MachineTestResponse", - "description": "Response body for ``POST /api/v1/machine/test``." + "required": ["id", "comment"], + "title": "SceneAnnotation", + "description": "Inline annotation metadata mirrored from prose markers." }, - "ModelCapabilities": { + "SceneBatchLinkProseRequest": { "properties": { - "multimodal": { + "scope_type": { + "type": "string", + "title": "Scope Type", + "default": "story" + }, + "chapter_id": { "anyOf": [ { - "type": "boolean" + "type": "string" }, { "type": "null" } ], - "title": "Multimodal" + "title": "Chapter Id" }, - "function_calling": { + "book_id": { "anyOf": [ { - "type": "boolean" + "type": "string" }, { "type": "null" } ], - "title": "Function Calling" + "title": "Book Id" + }, + "assignments": { + "items": { + "$ref": "#/components/schemas/SceneBoundaryAssignment" + }, + "type": "array", + "title": "Assignments" + }, + "unlink_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Unlink Ids" } }, "type": "object", - "title": "ModelCapabilities", - "description": "Subset of capabilities detected for a model (optional fields)." + "title": "SceneBatchLinkProseRequest", + "description": "Batch variant: atomically unlink and relink multiple scenes in one scope.\n\n``assignments`` are processed together with ``relink_scope_prose`` so\ntouching boundaries are never replayed through sequential single-scene\nedits that can split freshly inserted markers." }, - "ModelPresetEntry": { + "SceneBeat": { "properties": { "id": { "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "model_id_patterns": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Model Id Patterns" + "title": "Id", + "description": "Stable beat identifier. Usually generated automatically." }, - "preset_type": { + "text": { "type": "string", - "enum": ["absolute", "delta"], - "title": "Preset Type", - "default": "absolute" - }, - "parameters": { - "$ref": "#/components/schemas/ModelPresetParameters" + "title": "Text", + "description": "Short description of the beat's action or event." }, - "warnings": { + "prose_link": { "anyOf": [ { - "$ref": "#/components/schemas/ModelPresetWarning" + "$ref": "#/components/schemas/SceneProseLink" }, { "type": "null" } - ] + ], + "description": "Optional link from this beat to a specific prose range." } }, + "additionalProperties": false, "type": "object", - "required": ["id", "name", "description", "model_id_patterns", "parameters"], - "title": "ModelPresetEntry", - "description": "A single model-preset entry as loaded from *model_presets.json*.\n\n``preset_type`` distinguishes two flavours:\n\n* ``\"absolute\"`` (default) \u2013 replaces **all** sampling parameters on the\n provider and locks manual editing. Suitable for a named full profile\n tied to a specific model family.\n* ``\"delta\"`` \u2013 applies **only the non-null fields** in ``parameters`` on\n top of whatever is already configured. Does not lock the provider or\n change ``preset_id``. Suitable for cross-model tweaks such as \"more\n creative\" or \"factual focus\"." + "required": ["text"], + "title": "SceneBeat", + "description": "A single beat within a scene \u2013 a discrete micro-action or plot step." }, - "ModelPresetParameters": { + "SceneBoundaryAssignment": { "properties": { - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Temperature" + "scene_id": { + "type": "integer", + "title": "Scene Id" }, - "top_p": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Top P" + "start_offset": { + "type": "integer", + "title": "Start Offset" }, - "max_tokens": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Max Tokens" + "end_offset": { + "type": "integer", + "title": "End Offset" + } + }, + "type": "object", + "required": ["scene_id", "start_offset", "end_offset"], + "title": "SceneBoundaryAssignment", + "description": "One scene-to-prose boundary mapping in absolute offsets." + }, + "SceneChronologyTime": { + "properties": { + "temporal_zoned_datetime": { + "type": "string", + "title": "Temporal Zoned Datetime", + "description": "Normalized ISO 8601 timestamp for the scene's chronology." }, - "presence_penalty": { + "value": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Presence Penalty" + "title": "Value", + "description": "Optional shorthand scene time string accepted by the tool. When provided, it is normalized into temporal_zoned_datetime." + } + }, + "additionalProperties": false, + "type": "object", + "required": ["temporal_zoned_datetime"], + "title": "SceneChronologyTime", + "description": "Scene-local timeline point represented as a Temporal ZonedDateTime string." + }, + "SceneCreateRequest": { + "properties": { + "summary": { + "type": "string", + "title": "Summary", + "description": "Scene summary / label. Scenes do not have a separate title field; use this field instead. Keep it short, specific, and suitable for a scene card heading.", + "default": "" }, - "frequency_penalty": { + "beats": { + "items": { + "$ref": "#/components/schemas/SceneBeat" + }, + "type": "array", + "title": "Beats", + "description": "Optional ordered beats inside the scene. Use when the scene needs a micro-beat breakdown." + }, + "active_characters": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Active Characters", + "description": "Character IDs actively participating in the scene. Use sourcebook/character IDs, not display names, when available." + }, + "passive_characters": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Passive Characters", + "description": "Character IDs present but not actively driving the scene. Use sourcebook/character IDs, not display names, when available." + }, + "sourcebook_entry_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Sourcebook Entry Ids", + "description": "Sourcebook entry IDs needed to ground the scene's facts, setting, or canon references. Always include relevant sourcebook entries when creating a scene so the scene remains connected to existing world knowledge." + }, + "location": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Frequency Penalty" + "title": "Location", + "description": "Location identifier or location name for where the scene occurs." }, - "seed": { + "time": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Seed" + "title": "Time", + "description": "Human-readable scene time string when a formal chronology is not needed." }, - "top_k": { + "scene_time": { "anyOf": [ { - "type": "integer" + "$ref": "#/components/schemas/SceneChronologyTime" }, { "type": "null" } ], - "title": "Top K" + "description": "Formal timeline position for the scene. Always set this when the scene can be placed on the story timeline; if an exact timestamp is not known, use causes to capture relative chronology. Accepts ISO-like timestamps and normalizes them." }, - "min_p": { + "timeline_id": { + "type": "string", + "title": "Timeline Id", + "description": "Explicit timeline identity for this scene. Use 'main' for the primary timeline and stable IDs for branch timelines.", + "default": "main" + }, + "color_tag": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Min P" + "title": "Color Tag", + "description": "Optional color label for the scene card, usually a hex string." }, - "stop": { + "prose_link": { "anyOf": [ { - "items": { - "type": "string" - }, - "type": "array" + "$ref": "#/components/schemas/SceneProseLink" }, { "type": "null" } ], - "title": "Stop" + "description": "Optional prose link showing which content file the scene is linked to prose. Use this when the scene is already anchored to prose." + }, + "causes": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Causes", + "description": "IDs of scenes that this scene causally precedes. Use this to record cause/effect relationships without changing narrative order directly.", + "examples": [[1, 2]] + }, + "pinboard_x": { + "type": "number", + "title": "Pinboard X", + "description": "Pinboard X position in logical canvas units.", + "default": 100.0 + }, + "pinboard_y": { + "type": "number", + "title": "Pinboard Y", + "description": "Pinboard Y position in logical canvas units.", + "default": 100.0 + }, + "status": { + "type": "string", + "title": "Status", + "description": "Scene lifecycle status such as active, inactive, or draft.", + "default": "active" }, - "extra_body": { + "tag_personal_datetimes": { + "items": { + "$ref": "#/components/schemas/SceneTagPersonalDatetime" + }, + "type": "array", + "title": "Tag Personal Datetimes", + "description": "Per-tag personal age overrides used for time-travel or age-specific ordering. Leave empty unless you need those overrides." + } + }, + "additionalProperties": false, + "type": "object", + "title": "SceneCreateRequest", + "description": "Payload for creating a new scene." + }, + "SceneDetectBoundariesRequest": { + "properties": { + "scope_type": { + "type": "string", + "enum": ["story", "chapter", "unlinked"], + "title": "Scope Type", + "default": "chapter" + }, + "chapter_id": { "anyOf": [ { "type": "string" @@ -5209,16 +7803,9 @@ "type": "null" } ], - "title": "Extra Body" - } - }, - "type": "object", - "title": "ModelPresetParameters", - "description": "Typed parameters for a model preset." - }, - "ModelPresetWarning": { - "properties": { - "writing": { + "title": "Chapter Id" + }, + "book_id": { "anyOf": [ { "type": "string" @@ -5227,31 +7814,32 @@ "type": "null" } ], - "title": "Writing" - } - }, - "type": "object", - "title": "ModelPresetWarning", - "description": "Warnings that should be surfaced to the user for a preset." - }, - "OkSelectedResponse": { - "properties": { - "ok": { - "type": "boolean", - "title": "Ok" + "title": "Book Id" }, - "selected": { + "scene_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Scene Ids" + }, + "start_offset": { + "type": "integer", + "title": "Start Offset", + "default": 0 + }, + "end_offset": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Selected" + "title": "End Offset" }, - "detail": { + "prose_text": { "anyOf": [ { "type": "string" @@ -5260,37 +7848,53 @@ "type": "null" } ], - "title": "Detail" + "title": "Prose Text" } }, "type": "object", - "required": ["ok"], - "title": "OkSelectedResponse", - "description": "Response for ``PUT /api/v1/machine`` \u2013 returns the new selected model name." + "title": "SceneDetectBoundariesRequest", + "description": "Payload for boundary detection + optional automatic scene relinking." }, - "ProjectConvertRequest": { + "SceneDetectBoundariesResponse": { "properties": { - "target_type": { - "type": "string", - "title": "Target Type" + "assignments": { + "items": { + "$ref": "#/components/schemas/SceneBoundaryAssignment" + }, + "type": "array", + "title": "Assignments" + }, + "scenes": { + "items": { + "$ref": "#/components/schemas/Scene" + }, + "type": "array", + "title": "Scenes" } }, "type": "object", - "required": ["target_type"], - "title": "ProjectConvertRequest", - "description": "Represents the ProjectConvertRequest type." + "title": "SceneDetectBoundariesResponse", + "description": "Result of boundary detection + link updates." }, - "ProjectCreateRequest": { + "SceneLinkProseRequest": { "properties": { - "name": { + "scope_type": { "type": "string", - "title": "Name" + "title": "Scope Type", + "default": "story" }, - "type": { - "type": "string", - "title": "Type" + "chapter_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chapter Id" }, - "language": { + "book_id": { "anyOf": [ { "type": "string" @@ -5299,34 +7903,31 @@ "type": "null" } ], - "title": "Language", - "default": "en" - } - }, - "type": "object", - "required": ["name", "type"], - "title": "ProjectCreateRequest", - "description": "Represents the ProjectCreateRequest type." - }, - "ProjectDeleteRequest": { - "properties": { - "name": { - "type": "string", - "title": "Name" + "title": "Book Id" + }, + "start_offset": { + "type": "integer", + "title": "Start Offset" + }, + "end_offset": { + "type": "integer", + "title": "End Offset" } }, "type": "object", - "required": ["name"], - "title": "ProjectDeleteRequest", - "description": "Represents the ProjectDeleteRequest type." + "required": ["start_offset", "end_offset"], + "title": "SceneLinkProseRequest", + "description": "Payload for assigning a prose-text range to a scene.\n\nThe offsets are UTF-8 character positions within the referenced content\nfile. Validation against existing scene links happens in the service." }, - "ProjectImageInfo": { + "SceneProseLink": { "properties": { - "filename": { + "scope_type": { "type": "string", - "title": "Filename" + "enum": ["story", "chapter", "unlinked"], + "title": "Scope Type", + "description": "Which content scope the scene is linked to: 'story', 'chapter', or internal 'unlinked'." }, - "url": { + "chapter_id": { "anyOf": [ { "type": "string" @@ -5335,9 +7936,10 @@ "type": "null" } ], - "title": "Url" + "title": "Chapter Id", + "description": "Chapter ID when scope_type='chapter'. Leave empty for story or unlinked scope." }, - "description": { + "book_id": { "anyOf": [ { "type": "string" @@ -5346,64 +7948,86 @@ "type": "null" } ], - "title": "Description" + "title": "Book Id", + "description": "Book ID when the linked prose belongs to a book chapter. Leave empty for story or unlinked scope." }, - "title": { + "start_offset": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Title" + "title": "Start Offset", + "description": "Computed start offset within the linked content file." }, - "is_placeholder": { + "end_offset": { "anyOf": [ { - "type": "boolean" + "type": "integer" }, { "type": "null" } ], - "title": "Is Placeholder" + "title": "End Offset", + "description": "Computed end offset within the linked content file." } }, + "additionalProperties": false, "type": "object", - "required": ["filename"], - "title": "ProjectImageInfo", - "description": "Describes a single project image or image placeholder." + "required": ["scope_type"], + "title": "SceneProseLink", + "description": "A link between a scene (or beat) and a specific content file.\n\n ``scope_type`` distinguishes between:\n- ``'story'`` \u2013 the main story content file (short-story projects)\n- ``'chapter'`` \u2013 a specific chapter file (novel / series projects)\n - ``'unlinked'`` \u2013 internal planning prose for scenes not attached to\n story/chapter content\n\nOnly the file identity is persisted. ``start_offset`` and ``end_offset``\nare character positions derived at read time by parsing the inline HTML\ncomment markers embedded in the content file (see ``scene_markers.py``).\nThey are populated by the service layer before returning scenes to the API\nand are excluded from disk storage." }, - "ProjectInfo": { + "SceneReorderProseRequest": { "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" + "source_scene_id": { + "type": "integer", + "title": "Source Scene Id" }, - "path": { - "type": "string", - "title": "Path" + "target_scene_id": { + "type": "integer", + "title": "Target Scene Id" }, - "is_valid": { + "place_before": { "type": "boolean", - "title": "Is Valid" + "title": "Place Before", + "default": true + } + }, + "type": "object", + "required": ["source_scene_id", "target_scene_id"], + "title": "SceneReorderProseRequest", + "description": "Payload for reordering scenes within a linked prose scope." + }, + "SceneReorderProseResponse": { + "properties": { + "scenes": { + "items": { + "$ref": "#/components/schemas/Scene" + }, + "type": "array", + "title": "Scenes" }, - "title": { + "scope_type": { "type": "string", - "title": "Title" + "title": "Scope Type" }, - "type": { - "type": "string", - "title": "Type", - "default": "novel" + "chapter_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chapter Id" }, - "language": { + "book_id": { "anyOf": [ { "type": "string" @@ -5412,47 +8036,78 @@ "type": "null" } ], - "title": "Language" + "title": "Book Id" + }, + "scope_start": { + "type": "integer", + "title": "Scope Start" + }, + "scope_end": { + "type": "integer", + "title": "Scope End" + }, + "rebuilt_text": { + "type": "string", + "title": "Rebuilt Text" } }, "type": "object", - "required": ["id", "name", "path", "is_valid", "title"], - "title": "ProjectInfo", - "description": "Describes a single project entry returned by the listing endpoint." + "required": [ + "scenes", + "scope_type", + "scope_start", + "scope_end", + "rebuilt_text" + ], + "title": "SceneReorderProseResponse", + "description": "Result of a prose reorder transaction." }, - "ProjectListResponse": { + "SceneTagPersonalDatetime": { "properties": { - "current": { + "role": { "type": "string", - "title": "Current" + "enum": ["active", "passive", "sourcebook"], + "title": "Role", + "description": "Which tag list this override applies to." }, - "recent": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Recent" + "ref": { + "type": "string", + "title": "Ref", + "description": "Character name or sourcebook entry ID for the tag." }, - "available": { - "items": { - "$ref": "#/components/schemas/ProjectInfo" - }, - "type": "array", - "title": "Available" + "index": { + "type": "integer", + "title": "Index", + "description": "0-based position within the chosen tag list. Use this when the same character appears multiple times.", + "default": 0 + }, + "personal_age": { + "type": "string", + "title": "Personal Age", + "description": "Human-readable age such as '17y', '17y 3m', or '30d'." } }, + "additionalProperties": false, "type": "object", - "required": ["current", "recent", "available"], - "title": "ProjectListResponse", - "description": "Response body for ``GET /api/v1/projects``." + "required": ["role", "ref", "personal_age"], + "title": "SceneTagPersonalDatetime", + "description": "Personal age override for a single scene tag.\n\n``role`` is ``'active'``, ``'passive'``, or ``'sourcebook'``.\n``ref`` is the character name (for active/passive) or sourcebook entry ID\n(for sourcebook).\n``index`` is the 0-based position within the role's list \u2013 this allows\nthe same character to appear multiple times in one scene (e.g. a time\ntraveller meeting their younger self).\n``personal_age`` is a human-readable age string such as ``'17y'``,\n``'17y 3m'``, ``'5m 12d'``, or ``'30d'``." }, - "ProjectMutationResponse": { + "SceneUpdateProseContentRequest": { "properties": { - "ok": { - "type": "boolean", - "title": "Ok" - }, - "message": { + "text": { + "type": "string", + "title": "Text" + } + }, + "type": "object", + "required": ["text"], + "title": "SceneUpdateProseContentRequest", + "description": "Payload for replacing the prose text between a scene's inline markers." + }, + "SceneUpdateRequest": { + "properties": { + "summary": { "anyOf": [ { "type": "string" @@ -5461,59 +8116,55 @@ "type": "null" } ], - "title": "Message" + "title": "Summary", + "description": "Replacement scene label/summary. Use this instead of a title." }, - "detail": { + "beats": { "anyOf": [ { - "type": "string" + "items": { + "$ref": "#/components/schemas/SceneBeat" + }, + "type": "array" }, { "type": "null" } ], - "title": "Detail" + "title": "Beats", + "description": "Full replacement beat list for the scene." }, - "registry": { + "active_characters": { "anyOf": [ { - "$ref": "#/components/schemas/ProjectRegistry" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } - ] + ], + "title": "Active Characters", + "description": "Full replacement list of active character IDs." }, - "story": { - "anyOf": [ - { - "$ref": "#/components/schemas/StoryPayload" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": ["ok"], - "title": "ProjectMutationResponse", - "description": "Generic response for project create/delete/convert operations." - }, - "ProjectRegistry": { - "properties": { - "current": { + "passive_characters": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], - "title": "Current" + "title": "Passive Characters", + "description": "Full replacement list of passive character IDs." }, - "recent": { + "sourcebook_entry_ids": { "anyOf": [ { "items": { @@ -5525,67 +8176,22 @@ "type": "null" } ], - "title": "Recent" + "title": "Sourcebook Entry Ids", + "description": "Full replacement list of sourcebook entry IDs." }, - "available": { + "location": { "anyOf": [ { - "items": { - "$ref": "#/components/schemas/ProjectRegistryEntry" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], - "title": "Available" - } - }, - "type": "object", - "title": "ProjectRegistry", - "description": "Registry summary returned alongside project mutations." - }, - "ProjectRegistryEntry": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "path": { - "type": "string", - "title": "Path" - }, - "is_valid": { - "type": "boolean", - "title": "Is Valid", - "default": true - } - }, - "type": "object", - "required": ["name", "path"], - "title": "ProjectRegistryEntry", - "description": "A single project registry entry embedded in mutation responses." - }, - "ProjectSelectRequest": { - "properties": { - "name": { - "type": "string", - "title": "Name" - } - }, - "type": "object", - "required": ["name"], - "title": "ProjectSelectRequest", - "description": "Represents the ProjectSelectRequest type." - }, - "ProjectSelectResponse": { - "properties": { - "ok": { - "type": "boolean", - "title": "Ok" + "title": "Location", + "description": "Replacement location identifier or name." }, - "message": { + "time": { "anyOf": [ { "type": "string" @@ -5594,29 +8200,33 @@ "type": "null" } ], - "title": "Message" + "title": "Time", + "description": "Replacement human-readable time string." }, - "registry": { + "scene_time": { "anyOf": [ { - "$ref": "#/components/schemas/ProjectRegistry" + "$ref": "#/components/schemas/SceneChronologyTime" }, { "type": "null" } - ] + ], + "description": "Replacement formal chronology timestamp for the scene." }, - "story": { + "timeline_id": { "anyOf": [ { - "$ref": "#/components/schemas/StoryPayload" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Timeline Id", + "description": "Replacement explicit timeline identity for the scene." }, - "error": { + "color_tag": { "anyOf": [ { "type": "string" @@ -5625,74 +8235,61 @@ "type": "null" } ], - "title": "Error" + "title": "Color Tag", + "description": "Replacement card color tag." }, - "error_message": { + "prose_link": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/SceneProseLink" }, { "type": "null" } ], - "title": "Error Message" - } - }, - "type": "object", - "required": ["ok"], - "title": "ProjectSelectResponse", - "description": "Response body for ``POST /api/v1/projects/select``." - }, - "PromptsResponse": { - "properties": { - "ok": { - "type": "boolean", - "title": "Ok" + "description": "Replacement prose link for the scene." }, - "system_messages": { + "causes": { "anyOf": [ { - "additionalProperties": { - "type": "string" + "items": { + "type": "integer" }, - "type": "object" + "type": "array" }, { "type": "null" } ], - "title": "System Messages" + "title": "Causes", + "description": "Replacement list of scene IDs that this scene causally precedes.", + "examples": [[1, 2]] }, - "user_prompts": { + "pinboard_x": { "anyOf": [ { - "additionalProperties": { - "type": "string" - }, - "type": "object" + "type": "number" }, { "type": "null" } ], - "title": "User Prompts" + "title": "Pinboard X", + "description": "Replacement pinboard X position." }, - "languages": { + "pinboard_y": { "anyOf": [ { - "items": { - "type": "string" - }, - "type": "array" + "type": "number" }, { "type": "null" } ], - "title": "Languages" + "title": "Pinboard Y", + "description": "Replacement pinboard Y position." }, - "project_language": { + "status": { "anyOf": [ { "type": "string" @@ -5701,70 +8298,45 @@ "type": "null" } ], - "title": "Project Language" - } - }, - "type": "object", - "required": ["ok"], - "title": "PromptsResponse", - "description": "Response for ``GET /api/v1/prompts``." - }, - "ReplaceAllRequest": { - "properties": { - "query": { - "type": "string", - "title": "Query", - "description": "Text or pattern to search for" - }, - "replacement": { - "type": "string", - "title": "Replacement", - "description": "Text to substitute in place of each match" - }, - "scope": { - "$ref": "#/components/schemas/SearchScope", - "default": "all" - }, - "case_sensitive": { - "type": "boolean", - "title": "Case Sensitive", - "default": false - }, - "is_regex": { - "type": "boolean", - "title": "Is Regex", - "default": false - }, - "is_phonetic": { - "type": "boolean", - "title": "Is Phonetic", - "default": false + "title": "Status", + "description": "Replacement lifecycle status such as active, inactive, or draft." }, - "active_chapter_id": { + "tag_personal_datetimes": { "anyOf": [ { - "type": "integer" + "items": { + "$ref": "#/components/schemas/SceneTagPersonalDatetime" + }, + "type": "array" }, { "type": "null" } ], - "title": "Active Chapter Id" + "title": "Tag Personal Datetimes", + "description": "Replacement per-tag personal age overrides. Use None to leave the field unchanged, or an explicit list to replace it." } }, + "additionalProperties": false, "type": "object", - "required": ["query", "replacement"], - "title": "ReplaceAllRequest", - "description": "Request to replace all occurrences of a search query." + "title": "SceneUpdateRequest", + "description": "Payload for updating an existing scene." }, - "ReplaceChangeLocation": { + "SceneWriteRequest": { "properties": { - "type": { - "type": "string", - "title": "Type", - "description": "One of: chapter, story, metadata, sourcebook, book" + "scope_type": { + "anyOf": [ + { + "type": "string", + "enum": ["story", "chapter", "unlinked"] + }, + { + "type": "null" + } + ], + "title": "Scope Type" }, - "target_id": { + "chapter_id": { "anyOf": [ { "type": "string" @@ -5773,10 +8345,9 @@ "type": "null" } ], - "title": "Target Id", - "description": "Target identifier for the changed section, e.g. chapter ID or sourcebook entry name" + "title": "Chapter Id" }, - "field": { + "book_id": { "anyOf": [ { "type": "string" @@ -5785,124 +8356,51 @@ "type": "null" } ], - "title": "Field", - "description": "Optional field name or metadata subfield affected by the replacement" + "title": "Book Id" }, - "label": { - "type": "string", - "title": "Label", - "description": "Human-readable label for the changed section" - } - }, - "type": "object", - "required": ["type", "label"], - "title": "ReplaceChangeLocation", - "description": "Structured information about a single replaced section." - }, - "ReplaceResponse": { - "properties": { - "replacements_made": { + "include_following_scenes": { "type": "integer", - "title": "Replacements Made", - "description": "Total number of occurrences replaced", - "default": 0 - }, - "changed_sections": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Changed Sections", - "description": "Human-readable labels for each changed section" + "title": "Include Following Scenes", + "default": 1 }, - "changed_sections_meta": { - "items": { - "$ref": "#/components/schemas/ReplaceChangeLocation" - }, - "type": "array", - "title": "Changed Sections Meta", - "description": "Structured information for each changed section" + "detect_boundaries": { + "type": "boolean", + "title": "Detect Boundaries", + "default": true } }, "type": "object", - "title": "ReplaceResponse", - "description": "Result of a replace operation." + "title": "SceneWriteRequest", + "description": "Payload for generating prose for one scene and linking the result." }, - "ReplaceSingleRequest": { + "SceneWriteResponse": { "properties": { - "query": { - "type": "string", - "title": "Query", - "description": "Text or pattern to search for" - }, - "replacement": { - "type": "string", - "title": "Replacement", - "description": "Text to substitute in place of the match" - }, - "scope": { - "$ref": "#/components/schemas/SearchScope", - "default": "all" - }, - "case_sensitive": { - "type": "boolean", - "title": "Case Sensitive", - "default": false - }, - "is_regex": { - "type": "boolean", - "title": "Is Regex", - "default": false - }, - "is_phonetic": { - "type": "boolean", - "title": "Is Phonetic", - "default": false - }, - "active_chapter_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Active Chapter Id" - }, - "section_type": { - "type": "string", - "title": "Section Type", - "description": "The section_type value from SearchResultSection" + "scene": { + "$ref": "#/components/schemas/Scene" }, - "section_id": { + "generated_text": { "type": "string", - "title": "Section Id", - "description": "The section_id value from SearchResultSection" + "title": "Generated Text" }, - "field": { - "type": "string", - "title": "Field", - "description": "The field value from SearchResultSection" + "assignments": { + "items": { + "$ref": "#/components/schemas/SceneBoundaryAssignment" + }, + "type": "array", + "title": "Assignments" }, - "match_index": { - "type": "integer", - "minimum": 0.0, - "title": "Match Index", - "description": "Zero-based index of the match within this section+field" + "scenes": { + "items": { + "$ref": "#/components/schemas/Scene" + }, + "type": "array", + "title": "Scenes" } }, "type": "object", - "required": [ - "query", - "replacement", - "section_type", - "section_id", - "field", - "match_index" - ], - "title": "ReplaceSingleRequest", - "description": "Request to replace one specific match (identified by its ordinal index)." + "required": ["scene", "generated_text"], + "title": "SceneWriteResponse", + "description": "Result of writing one scene and relinking affected scenes." }, "SearchMatch": { "properties": { @@ -5980,6 +8478,7 @@ "description": "Chapter ID to use when scope is current_chapter" } }, + "additionalProperties": false, "type": "object", "required": ["query"], "title": "SearchOptions", @@ -6113,6 +8612,55 @@ "type": "array", "title": "Relations", "default": [] + }, + "origin_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origin Date" + }, + "destination_datetime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Datetime" + }, + "destination_relative": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Relative" + }, + "creates_new_timeline": { + "type": "boolean", + "title": "Creates New Timeline", + "default": false + }, + "timeline_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timeline Id" } }, "type": "object", @@ -6164,6 +8712,55 @@ "type": "array", "title": "Relations", "default": [] + }, + "origin_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origin Date" + }, + "destination_datetime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Datetime" + }, + "destination_relative": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Relative" + }, + "creates_new_timeline": { + "type": "boolean", + "title": "Creates New Timeline", + "default": false + }, + "timeline_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timeline Id" } }, "type": "object", @@ -6247,6 +8844,61 @@ } ], "title": "Relations" + }, + "origin_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origin Date" + }, + "destination_datetime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Datetime" + }, + "destination_relative": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Relative" + }, + "creates_new_timeline": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Creates New Timeline" + }, + "timeline_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timeline Id" } }, "type": "object", @@ -6333,16 +8985,16 @@ "title": "Direction", "default": "forward" }, - "start_chapter": { + "start_scene": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Start Chapter" + "title": "Start Scene" }, "start_book": { "anyOf": [ @@ -6355,16 +9007,16 @@ ], "title": "Start Book" }, - "end_chapter": { + "end_scene": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "End Chapter" + "title": "End Scene" }, "end_book": { "anyOf": [ @@ -6652,6 +9304,18 @@ ], "title": "Tags" }, + "annotations": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Annotations" + }, "image_style": { "anyOf": [ { @@ -6748,6 +9412,20 @@ } ], "title": "Chapters" + }, + "scenes": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Scene" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scenes" } }, "type": "object", @@ -6853,6 +9531,61 @@ } ], "title": "Relations" + }, + "origin_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origin Date" + }, + "destination_datetime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Datetime" + }, + "destination_relative": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination Relative" + }, + "creates_new_timeline": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Creates New Timeline" + }, + "timeline_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timeline Id" } }, "type": "object", @@ -6930,6 +9663,18 @@ "title": "StoryTagsResponse", "description": "Response for ``PUT /api/v1/story/tags``." }, + "UpdateAnnotationRequest": { + "properties": { + "comment": { + "type": "string", + "title": "Comment" + } + }, + "type": "object", + "required": ["comment"], + "title": "UpdateAnnotationRequest", + "description": "Payload for updating an annotation's comment." + }, "ValidationError": { "properties": { "loc": { @@ -6966,6 +9711,61 @@ "required": ["loc", "msg", "type"], "title": "ValidationError" }, + "ViewStatePayload": { + "properties": { + "current_chapter_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Current Chapter Id" + }, + "scroll_position": { + "type": "integer", + "title": "Scroll Position", + "default": 0 + }, + "workspace_mode": { + "type": "string", + "title": "Workspace Mode", + "default": "page" + }, + "scenes_view_type": { + "type": "string", + "title": "Scenes View Type", + "default": "narrative" + } + }, + "type": "object", + "title": "ViewStatePayload", + "description": "View state for a single project.\n\nAll fields are optional so partial updates are safe; the service layer\nfills missing fields with defaults on load." + }, + "ViewStateResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok", + "default": true + }, + "view_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/ViewStatePayload" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "title": "ViewStateResponse", + "description": "Response body for view state endpoints." + }, "augmentedquill__models__chat__OkResponse": { "properties": { "ok": { diff --git a/resources/config/instructions.json b/resources/config/instructions.json index df5e1649..e9f2de38 100644 --- a/resources/config/instructions.json +++ b/resources/config/instructions.json @@ -112,15 +112,15 @@ "You are a professional creative writing partner and the orchestration brain of the narration workflow.", "You are helping the user write a story.", "Your job is to keep the story logical, coherent, and well-paced by managing metadata, available structure, sourcebook knowledge, and delegation.", - "The active project structure is determined by the tools available in this session.", "Only use tools that are actually available in this session.", + "The active project structure is determined by the tools available in this session.", "", "Your goal is to assist with brainstorming, structuring, and coordinating the writing process.", "You own story metadata, active-draft metadata, sourcebook evolution, available structural ordering, and workflow progression.", "Treat conflicts on the active writing unit as the authoritative source of unresolved dramatic tension; do not substitute generic notes for conflicts.", - "If a conflict lacks a resolution plan, propose one with a specific draft target or scene target and ask the user to confirm before proceeding.", - "- Do NOT call call_writing_llm until one or more active conflicts are documented for the target writing unit (story/chapter) and a resolution direction is in place.", - "Use call_writing_llm whenever new story prose, new scenes, or other fresh narrative text must be produced. call_writing_llm is stateless: the WRITING LLM sees only the instruction/context you pass in that single call. Always include all required context explicitly (relevant chapter excerpts, conflict status, constraints, POV/style guidance, and exact IDs).", + "Where possible, propose a resolution direction with a specific draft target or scene target and ask the user to confirm before proceeding.", + "- Do NOT create or update scenes until one or more active conflicts are documented for the target writing unit (story/chapter), and at least one conflict has a resolution direction for this unit.", + "- Do NOT call call_writing_llm until one or more active conflicts are documented for the target writing unit (story/chapter), and at least one conflict has a resolution direction for this unit.", "Use call_editing_assistant ONLY when existing prose text already stored in the current draft must be corrected, refined, rewritten, or structurally revised.", "", "DEFAULT WORKFLOW (guideline, not a rigid law):", @@ -128,43 +128,54 @@ "2. Ensure the story has a fitting title and style tags.", "3. Create a preliminary story summary.", "4. Fill the sourcebook with relevant characters, locations, organizations, lore, items, and events.", - "5. Derive the available structure, then create notes for each writing unit before prose.", - "6. Track conflicts and likely resolutions on each active writing unit, then refine summaries. Always review conflict status first, then notes.", - "7. Write the actual prose in the structure shown by the active project overview.", - "8. Revisit earlier metadata whenever later discoveries reveal a gap or inconsistency.", + "5. Derive the available structure, then create notes for each writing unit.", + "6. Define conflicts and, where possible, resolution directions for the target writing unit, then refine summary/notes to match.", + "7. After summary + sourcebook baseline + conflicts are set, use manage_scenes to derive plot progression as explicit scenes.", + "8. Confirm scenes are complete and explicitly accepted by the user before starting prose writing.", + "9. Write the actual prose in the structure shown by the active project overview, grounded in the accepted scenes.", + "10. Revisit earlier metadata whenever later discoveries reveal a gap or inconsistency.", + "", + "SCENE-PLANNING GATE (MANDATORY):", + "- Before scene creation, ensure target conflicts are explicitly set and at least one conflict has a resolution direction for this unit.", + "- After summary + sourcebook baseline + conflicts, derive and refine plot progression with manage_scenes.", + "- When the user asks you to update scene or sourcebook metadata, do not pause to ask for confirmation; proceed with the best supported tool_calls. You already know the metadata rule.", + "- Every scene must include all active and passive characters present in that scene, the location, and preferably the date/time. When relevant lore exists for a scene, add it too. The same applies to related events, items, organizations, and any other meaningful connected sourcebook entries.", + "- If a sourcebook entry has a relation to the scene, that relation must be captured in the scene links or explicit sourcebook relations.", + "- Do NOT start prose generation until scenes are complete, aligned with conflicts, and explicitly accepted by the user.", "", "AUTONOMY:", "- Stay interactive when the user is exploring, deciding, or asking for options.", "- Work in a more autopilot manner when the user clearly wants execution.", - "- After major milestones, it is acceptable to pause and ask whether the user wants to continue unless the user asked for mostly autonomous progress.", + "- After major milestones, you may pause for confirmation unless the user asked for mostly autonomous progress.", "", "CORE TOOLS:", - "- call_writing_llm: Use this whenever new story prose, new scene text, or any other fresh narrative content must be written. This tool is stateless: never assume the WRITING LLM knows chapter order, chapter IDs, or current chapter content unless you pass that data in this call. Set write_mode='append' to add to the end of a chapter, 'replace' to swap out a specific passage (provide replace_target with the exact text to replace — first occurrence only), 'replace_all' to REPLACE THE COMPLETE EXISTING CONTENT of the chapter/draft (all prior text is permanently overwritten — use only for full rewrites), 'insert_at_marker' to insert at ~~~, or omit write_mode to get text without writing. For append continuity, provide preceding_content with the prose immediately before the insertion point when available; if omitted, the backend auto-fills it from the target chapter tail. Provide only the parameters shown in the active function schema.", + "- call_writing_llm: Use only after planning gates are satisfied. It is stateless, so always pass complete local context (IDs, target excerpts, conflict status, constraints, POV/style guidance). write_mode: append/replace/replace_all/insert_at_marker, or omit for preview-only text.", "- call_editing_assistant: Use this ONLY when existing prose in the active writing unit must be corrected, refined, rewritten, or structurally revised. The task argument must always reference actual stored project text.", - "- get_project_overview: List the current structure with IDs for the active project. This overview includes per-unit notes by default; omit them only when the overview must stay brief.", - "- get_story_metadata / update_story_metadata: Maintain story-level metadata (title, summary, tags, notes, conflicts when present). Prefer *_patch arguments (summary_patch, notes_patch, tags_patch, conflicts_patch) for partial edits so existing content is preserved unless explicit full replacement is intended. These are the only story-metadata tools; get_story_summary and set_story_tags no longer exist as separate tools.", + "- manage_project: Project lifecycle and structure overview with IDs. Use action='get_overview' to inspect chapters/books and IDs before chapter-targeted operations.", + "- manage_story_core: Story-level metadata + story-content reading + summary sync. Use actions 'get_metadata', 'update_metadata', 'read_content', and 'sync_summary'. Prefer patch fields in update_data for minimal edits.", "- get_chapter_metadata / update_chapter_metadata / get_chapter_summaries / write_chapter_summary / sync_summary: Maintain chapter-level metadata for projects that support chapters. Prefer *_patch arguments (summary_patch, notes_patch, conflicts_patch) for partial edits. Conflicts are first class here; prefer conflict updates over generic notes updates.", "- get_chapter_content: Inspect existing prose in projects that support chapters when deciding what to do next.", "- replace_text_in_chapter / insert_text_at_marker / apply_chapter_replacements: Make targeted edits inside chapters without rewriting the whole chapter. These tools apply only to projects that support chapters. For insert_text_at_marker, use the fixed marker `~~~` in the chapter text.", - "- search_in_project (scope='sourcebook') / list_sourcebook_entries / get_sourcebook_entry / create_sourcebook_entry / update_sourcebook_entry / delete_sourcebook_entry / add_sourcebook_relation / remove_sourcebook_relation: Maintain world knowledge. For update_sourcebook_entry prefer description_patch/synonyms_patch/images_patch when changing only part of a field. Use add_sourcebook_relation/remove_sourcebook_relation for relation-only edits instead of replacing relation sets.", + "- manage_sourcebook + search_and_replace(action='search', scope='sourcebook'): Maintain world knowledge. manage_sourcebook supports list/get/create/update/delete/add_relation/remove_relation via action and payload objects. When creating sourcebook entries, prefer attaching at least one logical relation to existing entities unless the entry is purely standalone background detail.", "- Keep metadata patch calls compact: send only the smallest single-field operation needed (avoid multi-field replacement payloads unless required).", "- sync_summary: Auto-generate a summary from the current project prose context.", "- undo_last_tool_changes: Undo the most recent LLM tool call's project changes (scope='last_call'), or roll back all project changes made by LLM tools since the last user prompt (scope='all_this_turn'). For 'all_this_turn', pass the batch_ids from all tool results in this turn. Use this to recover from unintended modifications before the user reviews them.", "- create_new_chapter / write_chapter_heading: Maintain chapter structure in projects that support chapters. Chapter and book reordering is done via the GUI or REST API, not via LLM tools.", - "- list_images / generate_image_description / create_image_placeholder / set_image_metadata: Manage project visuals.", - "- read_scratchpad / write_scratchpad: State management for per-chat, temporary planning state. Avoid storing permanent facts here; use story notes or sourcebook entries for durable world model updates.", - "- sync_story_summary: Delegate AI generation of the story-level summary to the EDITING model.", + "- manage_images: Manage project visuals with action-based operations (list, generate_description, create_placeholder, set_metadata).", + "- manage_scratchpad: Per-chat temporary planning state via action='read'/'write'.", + "- manage_scenes: Manage scene planning entities with action='list'/'get'/'create'/'update'/'delete'/'move'. Scenes do not have a separate title field; use create_data.summary as the scene label. Use create/update only after target conflicts are set and at least one conflict has a resolution direction for this unit. Use action='move' with scene_id + target_scene_id (+ place_before true/false) to reorder scenes, move between chapter scopes, and effectively link/unlink by moving into the target scope represented by the anchor scene. When creating scenes, include relevant sourcebook_entry_ids and a formal scene_time whenever the scene can be placed on the timeline. Use causes when only causal relationships are known.", "", "IDENTITY POLICY:", "- You MUST use the exact ID (numeric for chapters, UUID for books) provided by the tools.", "- NEVER guess an ID based on a title or sequence number.", - "- If you are unsure of an ID, call 'get_project_overview' first.", + "- If you are unsure of an ID, call manage_project(action='get_overview') first.", "", "IMPORTANT:", "- Do NOT write story prose yourself in the chat response. Use call_writing_llm instead.", "- Do NOT perform prose editing yourself in the chat response. Use call_editing_assistant instead.", - "- call_writing_llm can write directly to the project when you specify write_mode. Use write_mode='append' to continue the story, 'replace' to swap a specific existing passage with the generated text (set replace_target to the exact passage to replace — first occurrence only), 'replace_all' to REPLACE THE COMPLETE EXISTING CONTENT of the chapter/draft with newly generated text (WARNING: all prior text is permanently overwritten — only use when a full replacement is explicitly intended), 'insert_at_marker' for targeted insertion at ~~~, or omit write_mode to review the text before deciding. For chapter-based projects (novel/series), provide the chap_id. For short-story projects, chap_id is optional and will auto-detect. Important: call_writing_llm is stateless, so always pass the exact chapter/content context it must use in this request. For append, include preceding_content when you have a precise local anchor; otherwise the backend uses the last paragraphs of the target chapter. Without write_mode, it only returns text that you can then integrate via call_editing_assistant or other tools.", + "- call_writing_llm may write directly to the project only after required planning gates are satisfied (including scene completion + user acceptance).", + "- For chapter projects, always pass chap_id. For short stories, chap_id may be omitted for auto-detection.", "- The call_editing_assistant is a prose editor ONLY. It operates exclusively on existing project prose already stored in the active writing unit. NEVER use it for character analysis, psychological insights, world-building advice, brainstorming, answering questions, or any task that does not directly edit specific stored prose. Answer those requests yourself.", "- When returning text that may be used as tool arguments (e.g. JSON parameter values), use typographic quotation marks (“ ”) instead of straight double quotes (\") for string content to avoid breaking JSON formatting.", "- You may update metadata directly when appropriate because that is part of your role.", @@ -175,8 +186,9 @@ "TOOL USAGE:", "- When you need to access story information, use the available tools instead of guessing.", "- If context is missing or unclear, inspect the project with tools before deciding.", - "- Tools will be executed automatically and their results will be provided to you.", - "- Simply make normal conversational responses and tools will be called as needed" + "- Tools are NOT executed automatically. When you want to read or update project metadata, sourcebook content, or scene assignments, return an explicit tool_call for the appropriate tool instead of only describing the change in prose.", + "- Metadata such as sourcebook relations and scene sourcebook links are high priority. Keep these links coherent and up to date: update scene.sourcebook_entry_ids for scene-relevant entries, and use sourcebook relations to express entity connections explicitly.", + "- Simply make normal conversational responses when you are not invoking tools, and return explicit tool_calls when you need the system to perform an action." ] }, "editing_llm": { @@ -192,7 +204,7 @@ "You do not write completely new story parts yourself. When new prose, new scenes, or added narrative content are required, YOU MUST ALWAYS use call_writing_llm.", "When instructed to apply edits, fixes, or adjustments to the text, ALWAYS PREFER targeted replacement tools when they are available. In chapter-based projects, prefer 'replace_text_in_chapter' or 'apply_chapter_replacements' to modify parts of a chapter without rewriting the whole thing. Use 'insert_text_at_marker' when you want to target a specific cursor marker (e.g. `~~~`) inside a chapter. Only use whole-document write tools when replacing the ENTIRE current prose unit.", "CORE TOOLS:", - "- get_project_overview, get_story_metadata, get_book_metadata, get_chapter_metadata, get_chapter_summaries, get_chapter_content, read_story_content, search_in_project (scope='sourcebook'), list_sourcebook_entries, get_sourcebook_entry: Read the context you need before editing. (Note: get_story_summary, get_story_tags, get_chapter_summary, and get_chapter_heading are NOT separate tools — use get_story_metadata and get_chapter_metadata instead.)", + "- manage_project(action='get_overview'), manage_story_core(action='get_metadata' or 'read_content'), get_book_metadata, get_chapter_metadata, get_chapter_summaries, get_chapter_content, search_and_replace(action='search', scope='sourcebook'), manage_sourcebook(action='list' or 'get'): Read the context you need before editing.", "- replace_text_in_chapter: Modify small sections of a chapter safely in chapter-based projects. PREFER this over write_chapter_content to prevent output truncation on large texts.", "- apply_chapter_replacements: Apply multiple safe search/replace edits in a single operation for chapter-based projects.", "- insert_text_at_marker: Insert or replace text at a marker (`~~~`) so you can position a cursor within a chapter and have new prose inserted there.", @@ -200,13 +212,13 @@ "- write_story_content: Rewrite the ENTIRE story draft when a short-story project needs full-draft replacement. WARNING: replaces all existing text.", "- insert_image_in_chapter: Insert a Markdown image reference at a chosen location inside a chapter (end, marker, or after_paragraph:N) in chapter-based projects.", "- call_writing_llm: Use this whenever creative writing tasks or any net-new story content are required. The tool is stateless: the WRITING LLM only knows what you include in this call. Always provide complete local context and exact IDs instead of assuming prior chapter knowledge. For append requests, provide preceding_content when possible so continuation is anchored at the intended location.", - "- sync_story_summary: Trigger AI regeneration of the story-level summary from the current project prose context. You MAY call this after significant edits.", + "- manage_story_core(action='sync_summary'): Trigger AI regeneration of the story-level summary from the current project prose context. You MAY call this after significant edits.", "- read_editing_scratchpad / write_editing_scratchpad: Your own private state across turns — track your editing plan, open issues, and progress notes here.", "- recommend_metadata_updates: Report suggested changes to summaries, notes, conflicts, tags, or sourcebook content so CHAT can review and apply them. If story content directly contradicts sourcebook or character data in a way you cannot resolve via prose edits alone, describe the discrepancy in rationale so the user can decide.", "RULES:", - "- Never call metadata-writing or sourcebook-writing tools directly. Exception: sync_story_summary (AI generation trigger) is permitted.", + "- Never call metadata-writing or sourcebook-writing actions directly unless the user asks for metadata/sourcebook changes. Exception: manage_story_core(action='sync_summary') is permitted after substantial prose edits.", "- Never add new plot beats on your own. If new prose is needed, request it with call_writing_llm.", - "- Before any call_writing_llm request, verify the target unit with tools (for example get_project_overview/get_chapter_content) and pass the relevant chapter text and constraints in context.", + "- Before any call_writing_llm request, verify the target unit with tools (for example manage_project(action='get_overview') / get_chapter_content) and pass the relevant chapter text and constraints in context.", "- When returning text that may be used as tool arguments (e.g. JSON parameter values), use typographic quotation marks (“ ”) instead of straight double quotes (\") for string content to avoid breaking JSON formatting.", "- Always call a tool when you want to modify chapter prose or return structured metadata recommendations." ] @@ -252,16 +264,20 @@ "en": [ "You are a skilled novelist.", "Continue the current draft based on the provided story context, existing content, and summary.", + "Treat section headings and separator lines in the prompt as authoritative structure and use all provided sections.", + "Use scene guidance details explicitly (summary, beats, active/passive characters, location, and timeline cues when present).", + "Use next-scene preview details only as planning context; do not narrate events from the next scene early.", "Respect the story's themes, tags, and any unresolved conflicts provided in the prompt.", "Append the new text to the end. Do not repeat existing content." ] }, - "ai_action_chapter_rewrite": { - "_use": "WRITING", + "scene_boundary_linker": { + "_use": "EDITING", "en": [ - "You are a skilled novelist.", - "Rewrite the FULL content for the current draft based on the provided story context and strictly on the supplied summary.", - "Respect the story's themes, tags, and any unresolved conflicts provided in the prompt." + "You are a precise prose segmentation assistant.", + "Split the provided prose segment into contiguous ranges that map to the requested scene IDs.", + "Return strict JSON only in the requested schema.", + "Do not add commentary or markdown." ] }, "user_prompts": { @@ -357,38 +373,108 @@ "", "---", "", - "Existing draft text (do not change):", - "", - "{existing_text}", + "{existing_text_section}", "", "Task: Continue the current draft from where it stops to advance the summary coherently." ] }, - "chapter_ai_prefill_task": { + "write_scene_prose": { "_use": "WRITING", "en": [ - "Story title: {story_title}", + "# Story", "", - "Story description: {story_summary}", + "## Title", + "{story_title}", "", - "Story tags: {story_tags}", + "## Description", + "{story_summary}", "", - "Background information:\n{background}", + "{story_notes_section}", + "", + "## Tags", + "{story_tags}", + "", + "{background_section}", "", "---", "", - "Author's notes about the current draft:\n{chapter_notes}", + "# Current draft", "", - "Active draft conflicts:\n{chapter_conflicts}", + "## Title", + "{chapter_title}", "", - "Current draft title: {chapter_title}", + "## Summary", + "{chapter_summary}", "", - "Current draft summary:", + "{chapter_notes_section}", + "", + "---", + "", + "{scene_guidance_section}", + "", + "---", + "", + "{existing_tail_section}", + "", + "Task: Write prose for the current scene only. Return prose text only." + ] + }, + "scene_boundary_detection": { + "_use": "EDITING", + "en": [ + "Scene IDs (allowed): {scene_ids}", + "", + "Scene descriptions:\n{scene_descriptions}", + "", + "Prose segment:\n{prose_text}", + "", + "Task:", + "Return strict JSON only in this exact shape:", + "{{\"boundaries\": [{{\"scene_id\": 1, \"start_offset\": 0, \"end_offset\": 10}}]}}", + "Offsets must be 0-based and relative to the given prose segment.", + "Boundaries must be non-overlapping and ordered.", + "Map prose to scenes using semantic evidence from each scene description.", + "Do not guess: when evidence is weak, return fewer boundaries instead of forced assignments.", + "Only use scene IDs from the allowed list." + ] + }, + "chapter_ai_prefill_task": { + "_use": "WRITING", + "en": [ + "# Story", + "", + "## Title", + "{story_title}", + "", + "## Description", + "{story_summary}", + "", + "## Tags", + "{story_tags}", + "", + "{background}", + "", + "---", + "", + "# Current draft", + "", + "## Title", + "{chapter_title}", + "", + "## Summary", "{chapter_summary}", "", + "## Draft notes", + "{chapter_notes}", + "", + "## Active conflicts", + "{chapter_conflicts}", + "", "---", "", - "Task: Write the full current draft as continuous prose. Maintain voice and pacing." + "{existing_text_section}", + "", + "Task: Continue or rewrite the current draft according to the selected action while preserving coherence, voice, and pacing." ] }, "story_summary_new": { @@ -474,7 +560,7 @@ "en": [ "{header}", "", - "Reminder: manage workflow and metadata here; use call_writing_llm for new prose; use call_editing_assistant for prose refinement and review.", + "Reminder: manage workflow and metadata here; derive and confirm scenes after summary/sourcebook setup; only then use call_writing_llm for new prose; use call_editing_assistant for prose refinement and review.", "", "User Request: {user_text}" ] @@ -511,6 +597,10 @@ "_use": "WRITING", "en": [" Relations: {relation_text}"] }, + "sourcebook_entry_age": { + "_use": "WRITING", + "en": [" Age: {age_text} (as of {as_of_date})"] + }, "sourcebook_entry_relations_none": { "_use": "WRITING", "en": ["None"] diff --git a/resources/schemas/story-v2.schema.json b/resources/schemas/story-v2.schema.json index 10956b0c..441e1f7e 100644 --- a/resources/schemas/story-v2.schema.json +++ b/resources/schemas/story-v2.schema.json @@ -186,6 +186,10 @@ "items": { "type": "string" } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" } }, "required": ["description", "category"] diff --git a/resources/schemas/story-v3.schema.json b/resources/schemas/story-v3.schema.json new file mode 100644 index 00000000..3a6e0a40 --- /dev/null +++ b/resources/schemas/story-v3.schema.json @@ -0,0 +1,225 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/story-v3.schema.json", + "title": "Story Metadata Schema v3", + "description": "Schema for validating story.json files in AugmentedQuill projects, version 3", + "type": "object", + "properties": { + "project_title": { + "type": "string", + "description": "The title of the project" + }, + "project_type": { + "type": "string", + "enum": ["novel", "series", "short-story"], + "description": "The type of the project" + }, + "language": { + "type": "string", + "description": "Two\u2011letter language code used for prompt selection", + "default": "en" + }, + "chapters": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "description": "Chapter filename" + }, + { + "$ref": "#/$defs/chapter" + } + ] + }, + "description": "Array of chapters, either filenames or objects" + }, + "books": { + "type": "array", + "items": { + "$ref": "#/$defs/book" + }, + "description": "Array of books for series projects" + }, + "content_file": { + "type": "string", + "description": "The main content file" + }, + "format": { + "type": "string", + "enum": ["markdown"], + "description": "The format of the content" + }, + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "const": 3, + "description": "The metadata version for this schema" + } + }, + "required": ["version"] + }, + "llm_prefs": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["temperature", "max_tokens"], + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the project" + }, + "sourcebook": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/$defs/sourcebook_entry" + } + }, + "description": "Sourcebook entries" + }, + "story_summary": { + "type": "string", + "description": "Summary of the story" + }, + "sourcebook_relations": { + "type": "array", + "items": { + "$ref": "#/$defs/story_relation" + }, + "description": "Global list of all sourcebook relations" + } + }, + "required": ["project_title", "format", "metadata"], + "$defs": { + "chapter": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "resolution": { + "type": "string" + } + }, + "required": ["description", "resolution"] + } + }, + "notes": { + "type": "string" + }, + "private_notes": { + "type": "string" + } + }, + "required": ["title"] + }, + "book": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/$defs/chapter" + } + } + }, + "required": ["title", "chapters"] + }, + "sourcebook_entry": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + } + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" + } + }, + "required": ["description", "category"] + }, + "story_relation": { + "type": "object", + "properties": { + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "start_chapter": { + "type": ["string", "null"] + }, + "start_book": { + "type": ["string", "null"] + }, + "end_chapter": { + "type": ["string", "null"] + }, + "end_book": { + "type": ["string", "null"] + } + }, + "required": ["source_id", "target_id", "relation"] + } + } +} diff --git a/resources/schemas/story-v4.schema.json b/resources/schemas/story-v4.schema.json new file mode 100644 index 00000000..0e2a185e --- /dev/null +++ b/resources/schemas/story-v4.schema.json @@ -0,0 +1,225 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/story-v4.schema.json", + "title": "Story Metadata Schema v4", + "description": "Schema for validating story.json files in AugmentedQuill projects, version 4", + "type": "object", + "properties": { + "project_title": { + "type": "string", + "description": "The title of the project" + }, + "project_type": { + "type": "string", + "enum": ["novel", "series", "short-story"], + "description": "The type of the project" + }, + "language": { + "type": "string", + "description": "Two\u2011letter language code used for prompt selection", + "default": "en" + }, + "chapters": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "description": "Chapter filename" + }, + { + "$ref": "#/$defs/chapter" + } + ] + }, + "description": "Array of chapters, either filenames or objects" + }, + "books": { + "type": "array", + "items": { + "$ref": "#/$defs/book" + }, + "description": "Array of books for series projects" + }, + "content_file": { + "type": "string", + "description": "The main content file" + }, + "format": { + "type": "string", + "enum": ["markdown"], + "description": "The format of the content" + }, + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "const": 4, + "description": "The metadata version for this schema" + } + }, + "required": ["version"] + }, + "llm_prefs": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["temperature", "max_tokens"], + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the project" + }, + "sourcebook": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/$defs/sourcebook_entry" + } + }, + "description": "Sourcebook entries" + }, + "story_summary": { + "type": "string", + "description": "Summary of the story" + }, + "sourcebook_relations": { + "type": "array", + "items": { + "$ref": "#/$defs/story_relation" + }, + "description": "Global list of all sourcebook relations" + } + }, + "required": ["project_title", "format", "metadata"], + "$defs": { + "chapter": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "resolution": { + "type": "string" + } + }, + "required": ["description", "resolution"] + } + }, + "notes": { + "type": "string" + }, + "private_notes": { + "type": "string" + } + }, + "required": ["title"] + }, + "book": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/$defs/chapter" + } + } + }, + "required": ["title", "chapters"] + }, + "sourcebook_entry": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + } + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" + } + }, + "required": ["description", "category"] + }, + "story_relation": { + "type": "object", + "properties": { + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "start_chapter": { + "type": ["string", "null"] + }, + "start_book": { + "type": ["string", "null"] + }, + "end_chapter": { + "type": ["string", "null"] + }, + "end_book": { + "type": ["string", "null"] + } + }, + "required": ["source_id", "target_id", "relation"] + } + } +} diff --git a/resources/schemas/story-v5.schema.json b/resources/schemas/story-v5.schema.json new file mode 100644 index 00000000..3c588f6e --- /dev/null +++ b/resources/schemas/story-v5.schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stablellama.ai/schemas/story-v5.schema.json", + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "integer" + } + }, + "required": ["version"] + }, + "project_title": { + "type": "string", + "description": "Title of the project" + }, + "format": { + "type": "string", + "description": "Story file format" + }, + "project_type": { + "type": "string", + "description": "Project type" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the project" + }, + "sourcebook": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/$defs/sourcebook_entry" + } + }, + "description": "Sourcebook entries" + }, + "story_summary": { + "type": "string", + "description": "Summary of the story" + }, + "sourcebook_relations": { + "type": "array", + "items": { + "$ref": "#/$defs/story_relation" + }, + "description": "Global list of all sourcebook relations" + } + }, + "required": ["project_title", "format", "metadata"], + "$defs": { + "chapter": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "resolution": { + "type": "string" + } + }, + "required": ["description", "resolution"] + } + }, + "notes": { + "type": "string" + }, + "private_notes": { + "type": "string" + } + }, + "required": ["title"] + }, + "book": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/$defs/chapter" + } + } + }, + "required": ["title", "chapters"] + }, + "sourcebook_entry": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + } + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" + } + }, + "required": ["description", "category"] + }, + "story_relation": { + "type": "object", + "properties": { + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "start_chapter": { + "type": ["string", "null"] + }, + "start_book": { + "type": ["string", "null"] + }, + "end_chapter": { + "type": ["string", "null"] + }, + "end_book": { + "type": ["string", "null"] + } + }, + "required": ["source_id", "target_id", "relation"] + } + } +} diff --git a/resources/schemas/story-v6.schema.json b/resources/schemas/story-v6.schema.json new file mode 100644 index 00000000..ef1b9801 --- /dev/null +++ b/resources/schemas/story-v6.schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stablellama.ai/schemas/story-v6.schema.json", + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "integer" + } + }, + "required": ["version"] + }, + "project_title": { + "type": "string", + "description": "Title of the project" + }, + "format": { + "type": "string", + "description": "Story file format" + }, + "project_type": { + "type": "string", + "description": "Project type" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the project" + }, + "sourcebook": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/$defs/sourcebook_entry" + } + }, + "description": "Sourcebook entries" + }, + "story_summary": { + "type": "string", + "description": "Summary of the story" + }, + "sourcebook_relations": { + "type": "array", + "items": { + "$ref": "#/$defs/story_relation" + }, + "description": "Global list of all sourcebook relations" + } + }, + "required": ["project_title", "format", "metadata"], + "$defs": { + "chapter": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "resolution": { + "type": "string" + } + }, + "required": ["description", "resolution"] + } + }, + "notes": { + "type": "string" + }, + "private_notes": { + "type": "string" + } + }, + "required": ["title"] + }, + "book": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/$defs/chapter" + } + } + }, + "required": ["title", "chapters"] + }, + "sourcebook_entry": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + } + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" + } + }, + "required": ["description", "category"] + }, + "story_relation": { + "type": "object", + "properties": { + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "start_chapter": { + "type": ["string", "null"] + }, + "start_book": { + "type": ["string", "null"] + }, + "end_chapter": { + "type": ["string", "null"] + }, + "end_book": { + "type": ["string", "null"] + } + }, + "required": ["source_id", "target_id", "relation"] + } + } +} diff --git a/resources/schemas/story-v7.schema.json b/resources/schemas/story-v7.schema.json new file mode 100644 index 00000000..dc3965a7 --- /dev/null +++ b/resources/schemas/story-v7.schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stablellama.ai/schemas/story-v7.schema.json", + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "integer" + } + }, + "required": ["version"] + }, + "project_title": { + "type": "string", + "description": "Title of the project" + }, + "format": { + "type": "string", + "description": "Story file format" + }, + "project_type": { + "type": "string", + "description": "Project type" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the project" + }, + "sourcebook": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/$defs/sourcebook_entry" + } + }, + "description": "Sourcebook entries" + }, + "story_summary": { + "type": "string", + "description": "Summary of the story" + }, + "sourcebook_relations": { + "type": "array", + "items": { + "$ref": "#/$defs/story_relation" + }, + "description": "Global list of all sourcebook relations" + } + }, + "required": ["project_title", "format", "metadata"], + "$defs": { + "chapter": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "resolution": { + "type": "string" + } + }, + "required": ["description", "resolution"] + } + }, + "notes": { + "type": "string" + }, + "private_notes": { + "type": "string" + } + }, + "required": ["title"] + }, + "book": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/$defs/chapter" + } + } + }, + "required": ["title", "chapters"] + }, + "sourcebook_entry": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + } + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" + } + }, + "required": ["description", "category"] + }, + "story_relation": { + "type": "object", + "properties": { + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "start_chapter": { + "type": ["string", "null"] + }, + "start_book": { + "type": ["string", "null"] + }, + "end_chapter": { + "type": ["string", "null"] + }, + "end_book": { + "type": ["string", "null"] + } + }, + "required": ["source_id", "target_id", "relation"] + } + } +} diff --git a/resources/schemas/story-v8.schema.json b/resources/schemas/story-v8.schema.json new file mode 100644 index 00000000..616ba761 --- /dev/null +++ b/resources/schemas/story-v8.schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stablellama.ai/schemas/story-v8.schema.json", + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "integer" + } + }, + "required": ["version"] + }, + "project_title": { + "type": "string", + "description": "Title of the project" + }, + "format": { + "type": "string", + "description": "Story file format" + }, + "project_type": { + "type": "string", + "description": "Project type" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the project" + }, + "sourcebook": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/$defs/sourcebook_entry" + } + }, + "description": "Sourcebook entries" + }, + "story_summary": { + "type": "string", + "description": "Summary of the story" + }, + "sourcebook_relations": { + "type": "array", + "items": { + "$ref": "#/$defs/story_relation" + }, + "description": "Global list of all sourcebook relations" + } + }, + "required": ["project_title", "format", "metadata"], + "$defs": { + "chapter": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "resolution": { + "type": "string" + } + }, + "required": ["description", "resolution"] + } + }, + "notes": { + "type": "string" + }, + "private_notes": { + "type": "string" + } + }, + "required": ["title"] + }, + "book": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/$defs/chapter" + } + } + }, + "required": ["title", "chapters"] + }, + "sourcebook_entry": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + } + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" + } + }, + "required": ["description", "category"] + }, + "story_relation": { + "type": "object", + "properties": { + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "start_scene": { + "type": ["integer", "null"] + }, + "end_scene": { + "type": ["integer", "null"] + } + }, + "required": ["source_id", "target_id", "relation"] + } + } +} diff --git a/resources/schemas/story-v9.schema.json b/resources/schemas/story-v9.schema.json new file mode 100644 index 00000000..3e3dac69 --- /dev/null +++ b/resources/schemas/story-v9.schema.json @@ -0,0 +1,191 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stablellama.ai/schemas/story-v9.schema.json", + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "integer" + } + }, + "required": ["version"] + }, + "project_title": { + "type": "string", + "description": "Title of the project" + }, + "format": { + "type": "string", + "description": "Story file format" + }, + "project_type": { + "type": "string", + "description": "Project type" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the project" + }, + "annotations": { + "type": "array", + "items": { + "$ref": "#/$defs/annotation" + }, + "default": [], + "description": "Inline text annotations mirrored from marker tags" + }, + "sourcebook": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/$defs/sourcebook_entry" + } + }, + "description": "Sourcebook entries" + }, + "story_summary": { + "type": "string", + "description": "Summary of the story" + }, + "sourcebook_relations": { + "type": "array", + "items": { + "$ref": "#/$defs/story_relation" + }, + "description": "Global list of all sourcebook relations" + } + }, + "required": ["project_title", "format", "metadata"], + "$defs": { + "annotation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "comment": { + "type": "string" + }, + "scope_type": { + "type": "string", + "enum": ["story", "chapter", "unlinked"] + }, + "chapter_id": { + "type": ["string", "null"] + }, + "book_id": { + "type": ["string", "null"] + } + }, + "required": ["id", "comment"] + }, + "chapter": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "resolution": { + "type": "string" + } + }, + "required": ["description", "resolution"] + } + }, + "notes": { + "type": "string" + }, + "private_notes": { + "type": "string" + } + }, + "required": ["title"] + }, + "book": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/$defs/chapter" + } + } + }, + "required": ["title", "chapters"] + }, + "sourcebook_entry": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + } + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_date": { + "type": ["string", "null"], + "description": "ISO 8601 birth or creation date used to compute personal timeline age" + } + }, + "required": ["description", "category"] + }, + "story_relation": { + "type": "object", + "properties": { + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "start_scene": { + "type": ["integer", "null"] + }, + "end_scene": { + "type": ["integer", "null"] + } + }, + "required": ["source_id", "target_id", "relation"] + } + } +} diff --git a/src/augmentedquill/api/v1/annotations.py b/src/augmentedquill/api/v1/annotations.py new file mode 100644 index 00000000..bfe92eed --- /dev/null +++ b/src/augmentedquill/api/v1/annotations.py @@ -0,0 +1,151 @@ +# Copyright (C) 2026 StableLlama +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""REST endpoints for annotation management. + +All routes are scoped under ``/projects/{project_name}/annotations``. +""" + +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from augmentedquill.api.v1.dependencies import ProjectDep +from augmentedquill.services.annotations.annotation_service import ( + create_annotation, + delete_annotation, + get_annotation, + list_annotations, + list_annotations_for_scope, + update_annotation, +) + +router = APIRouter(prefix="/projects/{project_name}", tags=["Annotations"]) + + +# ─── Request / response models ─────────────────────────────────────────────── + + +class AnnotationResponse(BaseModel): + """A single annotation with runtime offsets attached.""" + + id: str + comment: str + scope_type: str + chapter_id: Optional[str] = None + book_id: Optional[str] = None + start_offset: Optional[int] = None + end_offset: Optional[int] = None + + +class CreateAnnotationRequest(BaseModel): + """Payload for creating a new annotation.""" + + scope_type: str = Field(..., description="'story', 'chapter', or 'unlinked'") + chapter_id: Optional[str] = Field(None, description="Chapter ID for chapter scope") + book_id: Optional[str] = Field(None, description="Book ID for nested chapter scope") + start_offset: int = Field( + ..., description="Raw character offset (markers included)" + ) + end_offset: int = Field(..., description="Raw character offset (markers included)") + comment: str = Field(..., description="Human-readable annotation comment") + + +class UpdateAnnotationRequest(BaseModel): + """Payload for updating an annotation's comment.""" + + comment: str + + +# ─── Routes ────────────────────────────────────────────────────────────────── + + +@router.get("/annotations", response_model=List[AnnotationResponse]) +async def api_list_annotations(project_dir: ProjectDep) -> List[AnnotationResponse]: + """List all annotations for the project.""" + return [AnnotationResponse(**a) for a in list_annotations(project_dir)] + + +@router.get("/annotations/scope", response_model=List[AnnotationResponse]) +async def api_list_annotations_for_scope( + project_dir: ProjectDep, + scope_type: str = "story", + chapter_id: Optional[str] = None, + book_id: Optional[str] = None, +) -> List[AnnotationResponse]: + """List annotations for a specific prose scope.""" + return [ + AnnotationResponse(**a) + for a in list_annotations_for_scope( + project_dir, scope_type, chapter_id, book_id + ) + ] + + +@router.post("/annotations", response_model=AnnotationResponse, status_code=201) +async def api_create_annotation( + project_dir: ProjectDep, + payload: CreateAnnotationRequest, +) -> AnnotationResponse: + """Create a new inline annotation.""" + try: + result = create_annotation( + project_dir, + scope_type=payload.scope_type, + chapter_id=payload.chapter_id, + book_id=payload.book_id, + start_offset=payload.start_offset, + end_offset=payload.end_offset, + comment=payload.comment, + ) + except (ValueError, KeyError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return AnnotationResponse(**result) + + +@router.get("/annotations/{annotation_id}", response_model=AnnotationResponse) +async def api_get_annotation( + project_dir: ProjectDep, + annotation_id: str, +) -> AnnotationResponse: + """Fetch a single annotation by ID.""" + ann = get_annotation(project_dir, annotation_id) + if ann is None: + raise HTTPException( + status_code=404, detail=f"Annotation '{annotation_id}' not found" + ) + return AnnotationResponse(**ann) + + +@router.put("/annotations/{annotation_id}", response_model=AnnotationResponse) +async def api_update_annotation( + project_dir: ProjectDep, + annotation_id: str, + payload: UpdateAnnotationRequest, +) -> AnnotationResponse: + """Update an annotation's comment.""" + ann = update_annotation(project_dir, annotation_id, comment=payload.comment) + if ann is None: + raise HTTPException( + status_code=404, detail=f"Annotation '{annotation_id}' not found" + ) + return AnnotationResponse(**ann) + + +@router.delete("/annotations/{annotation_id}", status_code=204) +async def api_delete_annotation( + project_dir: ProjectDep, + annotation_id: str, +) -> None: + """Delete an annotation and remove its inline markers.""" + if not delete_annotation(project_dir, annotation_id): + raise HTTPException( + status_code=404, detail=f"Annotation '{annotation_id}' not found" + ) diff --git a/src/augmentedquill/api/v1/chat.py b/src/augmentedquill/api/v1/chat.py index 3638c5e5..5abfbbb9 100644 --- a/src/augmentedquill/api/v1/chat.py +++ b/src/augmentedquill/api/v1/chat.py @@ -49,7 +49,10 @@ capture_project_snapshot, restore_project_snapshot, ) -from augmentedquill.services.projects.projects import use_project_context +from augmentedquill.services.projects.projects import ( + get_active_project_dir, + use_project_context, +) from augmentedquill.services.chat.chat_api_session_ops import ( list_active_chats, load_active_chat, @@ -60,6 +63,7 @@ import augmentedquill.services.chat.chat_api_proxy_ops as _chat_api_proxy_ops import json as _json from typing import Any, Dict +from collections import OrderedDict from augmentedquill.models.chat import ( ChatInitialStateResponse, ChatToolBatchMutationResponse, @@ -82,6 +86,280 @@ _CHAT_TOOL_BATCH_DIR = ".aq_history/chat_tool_batches" _BATCH_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,80}$") +_TOOLS_ALWAYS_INCLUDED_FOR_NARROWING = frozenset({"undo_last_tool_changes"}) + +# High-confidence intent signatures that are safe to narrow. +_INTENT_TOOL_CLOSURE_RULES: tuple[tuple[re.Pattern[str], frozenset[str]], ...] = ( + ( + re.compile( + r"\b(scene|scenes)\b.*\b(move|reorder|re-order|before|after|relink|re-link|scope|chapter)\b" + r"|\b(move|reorder|re-order|relink|re-link)\b.*\b(scene|scenes)\b", + re.IGNORECASE, + ), + frozenset({"manage_scenes"}), + ), + ( + re.compile(r"\b(scene|scenes)\b.*\b(list|show|get|read)\b", re.IGNORECASE), + frozenset({"manage_scenes"}), + ), + ( + re.compile( + r"\b(chapter|chapters)\b.*\b(summary|summaries|metadata|heading|content|write)\b" + r"|\b(summary|summaries|metadata|heading|content)\b.*\b(chapter|chapters)\b", + re.IGNORECASE, + ), + frozenset( + { + "get_chapter_metadata", + "update_chapter_metadata", + "get_chapter_summaries", + "get_chapter_content", + "get_current_chapter_id", + "write_chapter_summary", + "write_chapter_heading", + "sync_summary", + } + ), + ), + ( + re.compile( + r"\b(sourcebook|character|location|worldbuilding|world-building)\b", + re.IGNORECASE, + ), + frozenset({"manage_sourcebook"}), + ), + ( + re.compile( + r"\b(image|images|cover|art|illustration|illustrate)\b", re.IGNORECASE + ), + frozenset({"manage_images"}), + ), + ( + re.compile(r"\b(search|replace|find all|regex)\b", re.IGNORECASE), + frozenset({"search_and_replace"}), + ), +) + + +def _extract_latest_user_text(messages: list[dict[str, Any]]) -> str: + """Extract the latest user textual content for deterministic intent routing.""" + for msg in reversed(messages): + if str(msg.get("role") or "").lower() != "user": + continue + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if not isinstance(part, dict): + continue + if str(part.get("type") or "") == "text": + text_val = part.get("text") + if isinstance(text_val, str): + text_parts.append(text_val) + return "\n".join(text_parts) + return "" + + +def _extract_recent_user_text( + messages: list[dict[str, Any]], max_user_messages: int = 3 +) -> str: + """Extract recent user text across turns to preserve follow-up intent context.""" + if max_user_messages < 1: + max_user_messages = 1 + + collected: list[str] = [] + for msg in reversed(messages): + if str(msg.get("role") or "").lower() != "user": + continue + content = msg.get("content") + if isinstance(content, str) and content.strip(): + collected.append(content) + elif isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if not isinstance(part, dict): + continue + if str(part.get("type") or "") == "text": + text_val = part.get("text") + if isinstance(text_val, str) and text_val.strip(): + text_parts.append(text_val) + if text_parts: + collected.append("\n".join(text_parts)) + if len(collected) >= max_user_messages: + break + + return "\n".join(reversed(collected)) + + +def _narrow_story_tools_for_messages( + story_tools: list[dict[str, Any]] | None, + messages: list[dict[str, Any]], + tool_choice: Any, +) -> list[dict[str, Any]] | None: + """Narrow tool list for high-confidence intents, else keep full list. + + The selector is deterministic and conservative: + - If no confident intent is detected, return full tools. + - If narrowing would result in an empty/missing required set, return full tools. + - Explicit client tool_choice overrides bypass narrowing. + """ + if not story_tools: + return story_tools + + # Respect explicit tool-choice directives from clients. + if tool_choice not in (None, "", "auto"): + return story_tools + + intent_context_text = _extract_recent_user_text(messages) + if not intent_context_text.strip(): + return story_tools + + required_tool_names: set[str] = set() + for pattern, required in _INTENT_TOOL_CLOSURE_RULES: + if pattern.search(intent_context_text): + required_tool_names.update(required) + + if not required_tool_names: + return story_tools + + required_tool_names.update(_TOOLS_ALWAYS_INCLUDED_FOR_NARROWING) + available_tool_names = { + str((tool.get("function") or {}).get("name") or "") for tool in story_tools + } + + # Fallback to full set if mandatory closure tools are not available in this role/project. + if not required_tool_names.intersection(available_tool_names): + return story_tools + + selected = [ + tool + for tool in story_tools + if str((tool.get("function") or {}).get("name") or "") in required_tool_names + ] + # Safety fallback: never send an empty or effectively empty narrowed set. + if not selected: + return story_tools + + return selected + + +def _is_manage_scenes_update_batch( + tool_calls: list, project_language: str, model_type: str +) -> bool: + """Return true when a tool-call batch is exclusively manage_scenes updates.""" + if model_type != CHAT_ROLE or len(tool_calls) < 2: + return False + + for call in tool_calls: + if not isinstance(call, dict): + return False + func = call.get("function") or {} + name = (func.get("name") if isinstance(func, dict) else None) or "" + if str(name) != "manage_scenes": + return False + args_raw = (func.get("arguments") if isinstance(func, dict) else None) or "{}" + try: + args_obj = ( + try_parse_json_robust(args_raw, language=project_language) + if isinstance(args_raw, str) + else (args_raw or {}) + ) + except (ValueError, TypeError): + return False + if not isinstance(args_obj, dict): + return False + if str(args_obj.get("action") or "").strip().lower() != "update": + return False + return True + + +def _aggregate_manage_scenes_update_batch_payload( + buffered_messages: list[dict], +) -> tuple[Any | None, bool]: + """Aggregate manage_scenes update messages into one payload. + + Returns (payload, ok). When ok is false, caller should fall back to raw messages. + """ + content_scenes_by_id: OrderedDict[int, dict[str, Any]] = OrderedDict() + scene_list_by_id: OrderedDict[int, dict[str, Any]] = OrderedDict() + saw_content = False + saw_placement = False + + for msg in buffered_messages: + if not isinstance(msg, dict): + return None, False + content_raw = msg.get("content") + if not isinstance(content_raw, str): + return None, False + try: + payload = _json.loads(content_raw) + except (TypeError, ValueError): + return None, False + + if isinstance(payload, dict) and payload.get("error"): + return None, False + + if isinstance(payload, list): + saw_placement = True + for entry in payload: + if not isinstance(entry, dict): + continue + scene_id = int(entry.get("scene_id") or 0) + if scene_id <= 0: + continue + if scene_id in scene_list_by_id: + del scene_list_by_id[scene_id] + scene_list_by_id[scene_id] = entry + continue + + if isinstance(payload, dict) and "scene" in payload and "scene_list" in payload: + scene_obj = payload.get("scene") + scene_list = payload.get("scene_list") + if isinstance(scene_obj, dict): + scene_id = int(scene_obj.get("id") or 0) + if scene_id > 0: + if scene_id in content_scenes_by_id: + del content_scenes_by_id[scene_id] + content_scenes_by_id[scene_id] = scene_obj + saw_content = True + if isinstance(scene_list, list): + saw_placement = True + for entry in scene_list: + if not isinstance(entry, dict): + continue + scene_id = int(entry.get("scene_id") or 0) + if scene_id <= 0: + continue + if scene_id in scene_list_by_id: + del scene_list_by_id[scene_id] + scene_list_by_id[scene_id] = entry + continue + + if isinstance(payload, dict) and int(payload.get("id") or 0) > 0: + scene_id = int(payload.get("id") or 0) + if scene_id in content_scenes_by_id: + del content_scenes_by_id[scene_id] + content_scenes_by_id[scene_id] = payload + saw_content = True + continue + + return None, False + + content_scenes = list(content_scenes_by_id.values()) + scene_list = list(scene_list_by_id.values()) + if saw_placement and saw_content: + return { + "scenes": content_scenes, + "scene_list": scene_list, + }, True + if saw_placement: + return scene_list, True + if saw_content: + return content_scenes, True + return None, False + async def _run_tool_calls( tool_calls: list, @@ -105,27 +383,33 @@ async def _run_tool_calls( story_cfg = load_story_config(active_dir / "story.json") or {} project_language = str(story_cfg.get("language", "en") or "en") - with use_project_context(active_dir): - for call in tool_calls: - if not isinstance(call, dict): - continue - call_id = str(call.get("id") or "") - func = call.get("function") or {} - name = (func.get("name") if isinstance(func, dict) else None) or "" - args_raw = ( - func.get("arguments") if isinstance(func, dict) else None - ) or "{}" - try: - args_obj = ( - try_parse_json_robust(args_raw, language=project_language) - if isinstance(args_raw, str) - else (args_raw or {}) - ) - except (ValueError, TypeError): - args_obj = {} - if not name or not call_id: - continue - tool_names.append(name) + current_active_dir = active_dir + aggregate_manage_scenes_updates = _is_manage_scenes_update_batch( + tool_calls=tool_calls, + project_language=project_language, + model_type=model_type, + ) + manage_scenes_update_buffer: list[dict] = [] + for call in tool_calls: + if not isinstance(call, dict): + continue + call_id = str(call.get("id") or "") + func = call.get("function") or {} + name = (func.get("name") if isinstance(func, dict) else None) or "" + args_raw = (func.get("arguments") if isinstance(func, dict) else None) or "{}" + try: + args_obj = ( + try_parse_json_robust(args_raw, language=project_language) + if isinstance(args_raw, str) + else (args_raw or {}) + ) + except (ValueError, TypeError): + args_obj = {} + if not name or not call_id: + continue + tool_names.append(name) + + with use_project_context(current_active_dir): msg = await execute_registered_tool( name, args_obj, @@ -134,10 +418,35 @@ async def _run_tool_calls( mutations, tool_role=model_type, ) - if isinstance(msg, dict) and "role" not in msg: - msg = tool_message(name, call_id, msg) + if isinstance(msg, dict) and "role" not in msg: + msg = tool_message(name, call_id, msg) + if aggregate_manage_scenes_updates: + manage_scenes_update_buffer.append(msg) + else: appended.append(msg) + # Follow project switches initiated by tools (e.g., manage_project/create) + # so subsequent tool calls in the same batch operate on the new project. + switched_project_dir = get_active_project_dir() + if switched_project_dir is not None: + current_active_dir = switched_project_dir + + if aggregate_manage_scenes_updates: + aggregated_payload, ok = _aggregate_manage_scenes_update_batch_payload( + manage_scenes_update_buffer + ) + if ok and aggregated_payload is not None and manage_scenes_update_buffer: + aggregate_call_id = str( + manage_scenes_update_buffer[-1].get("tool_call_id") + or manage_scenes_update_buffer[-1].get("id") + or "batch_manage_scenes_update" + ) + appended.append( + tool_message("manage_scenes", aggregate_call_id, aggregated_payload) + ) + else: + appended.extend(manage_scenes_update_buffer) + return appended, mutations, tool_names @@ -184,6 +493,7 @@ def _store_chat_tool_batch_snapshot( before_snapshot: Dict[str, str], after_snapshot: Dict[str, str], tool_names: list[str], + before_chapter_id_paths: Dict[str, str], ) -> list[int]: """Persist before/after snapshots for reversible tool-call batches. @@ -200,6 +510,7 @@ def _store_chat_tool_batch_snapshot( "created_at": datetime.datetime.now().isoformat(), "tool_names": tool_names, "changed_chapter_ids": changed_chapter_ids, + "chapter_id_paths": before_chapter_id_paths, "before": before_snapshot, "after": after_snapshot, } @@ -300,10 +611,17 @@ async def api_chat_tools( active_project_dir = project_dir before_snapshot: Dict[str, str] | None = None + before_chapter_id_paths: Dict[str, str] | None = None batch_id: str | None = None if active_project_dir and tool_calls: before_snapshot = capture_project_snapshot(active_project_dir) + from augmentedquill.services.chapters.chapter_helpers import _scan_chapter_files + + before_chapter_id_paths = { + str(vid): str(abs_path.relative_to(active_project_dir)) + for vid, abs_path in _scan_chapter_files(active_project_dir) + } batch_id = f"batch-{uuid4().hex}" async def _gen() -> Any: @@ -390,6 +708,7 @@ async def _watch_disconnect() -> None: active_project_dir and batch_id and before_snapshot is not None + and before_chapter_id_paths is not None and mutations.get("story_changed") ): after_snapshot = capture_project_snapshot(active_project_dir) @@ -399,6 +718,7 @@ async def _watch_disconnect() -> None: before_snapshot, after_snapshot, tool_names, + before_chapter_id_paths, ) mutations["tool_batch"] = { "batch_id": batch_id, @@ -474,19 +794,33 @@ async def api_chat_batch_chapter_before( batch = _load_chat_tool_batch_snapshot(project_dir, batch_id) before_snapshot: Dict[str, str] = batch.get("before") or {} + chapter_id_paths = batch.get("chapter_id_paths") or {} + + original_rel_path: str | None = None + if isinstance(chapter_id_paths, dict): + original_rel_path = chapter_id_paths.get(str(chapter_id)) chapter_files = _scan_chapter_files(project_dir) - rel_path: str | None = None + current_rel_path: str | None = None for vid, abs_path in chapter_files: if vid == chapter_id: - rel_path = str(abs_path.relative_to(project_dir)) + current_rel_path = str(abs_path.relative_to(project_dir)) break - if rel_path is None: - raise HTTPException(status_code=404, detail="Chapter not found in project") + candidates = [path for path in (current_rel_path, original_rel_path) if path] + rel_path: str | None = None + content_b64: str | None = None + for candidate in candidates: + content_b64 = before_snapshot.get(candidate) + if content_b64 is not None: + rel_path = candidate + break - content_b64 = before_snapshot.get(rel_path) - if content_b64 is None: + if rel_path is None or content_b64 is None: + # If the chapter exists now but had no prior snapshot entry, it was + # likely created during the batch. In that case the baseline is empty. + if current_rel_path is not None: + return ChapterBeforeContentResponse(content="") raise HTTPException( status_code=404, detail="Chapter not found in batch before-snapshot" ) @@ -675,6 +1009,16 @@ def _to_int(value: Any) -> None: # This prevents some models from hallucinating tool usage even when told not to. if tool_choice == "none": pass + elif model_type == CHAT_ROLE: + # Temporarily disable automatic tool narrowing so the model can + # choose from the full available tool set and we can confirm + # whether scene cleanup is being blocked by filtering. + # story_tools = _narrow_story_tools_for_messages( + # story_tools=story_tools, + # messages=req_messages, + # tool_choice=tool_choice, + # ) + pass if model_type == WRITING_ROLE: story_tools = None tool_choice = None diff --git a/src/augmentedquill/api/v1/scenes.py b/src/augmentedquill/api/v1/scenes.py new file mode 100644 index 00000000..5ec24cd9 --- /dev/null +++ b/src/augmentedquill/api/v1/scenes.py @@ -0,0 +1,281 @@ +# Copyright (C) 2026 StableLlama +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""REST endpoints for scene management. + +All routes are scoped under ``/projects/{project_name}/scenes`` and require a +valid, existing project directory resolved via the ``ProjectDep`` dependency. +""" + +from typing import List + +from fastapi import APIRouter, HTTPException + +from augmentedquill.api.v1.dependencies import ProjectDep +from augmentedquill.models.scene import ( + Scene, + SceneBatchLinkProseRequest, + SceneCreateRequest, + SceneDetectBoundariesRequest, + SceneDetectBoundariesResponse, + SceneId, + SceneLinkProseRequest, + SceneReorderProseRequest, + SceneReorderProseResponse, + SceneUpdateProseContentRequest, + SceneUpdateRequest, + SceneWriteRequest, + SceneWriteResponse, +) +from augmentedquill.services.scenes.scene_service import ( + create_scene, + delete_scene, + get_scene, + link_prose, + list_scenes, + relink_scope_prose, + reorder_scene_prose, + unlink_prose, + update_prose_content, + update_scene, +) +from augmentedquill.services.scenes.scene_generation_service import ( + detect_scene_boundaries_and_link, + auto_link_scope_text, + write_scene_and_link, +) + +router = APIRouter(prefix="/projects/{project_name}", tags=["Scenes"]) + + +@router.get("/scenes", response_model=List[Scene]) +async def get_scenes(project_dir: ProjectDep) -> List[Scene]: + """List all scenes for the project, with staleness flags on prose links.""" + return [Scene(**s) for s in list_scenes(project_dir)] + + +@router.post("/scenes", response_model=Scene, status_code=201) +async def create_new_scene( + project_dir: ProjectDep, + payload: SceneCreateRequest, +) -> Scene: + """Create a new scene.""" + return Scene(**create_scene(project_dir, payload)) + + +@router.get("/scenes/{scene_id}", response_model=Scene) +async def get_single_scene(project_dir: ProjectDep, scene_id: SceneId) -> Scene: + """Fetch a single scene by ID.""" + scene = get_scene(project_dir, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found") + return Scene(**scene) + + +@router.put("/scenes/{scene_id}", response_model=Scene) +async def update_existing_scene( + project_dir: ProjectDep, + scene_id: SceneId, + payload: SceneUpdateRequest, +) -> Scene: + """Update an existing scene (partial – only provided fields are changed).""" + scene = update_scene(project_dir, scene_id, payload) + if scene is None: + raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found") + return Scene(**scene) + + +@router.delete("/scenes/{scene_id}", status_code=204) +async def delete_existing_scene(project_dir: ProjectDep, scene_id: SceneId) -> None: + """Delete a scene and remove it from all order constraints.""" + if not delete_scene(project_dir, scene_id): + raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found") + + +class AutoLinkScopeRequest(SceneDetectBoundariesRequest): + """Body for auto-linking a saved prose scope to its scenes.""" + + current_text: str + + +class AutoLinkScopeResponse(SceneDetectBoundariesResponse): + """Response for auto-linking a saved prose scope to its scenes.""" + + pass + + +@router.post("/scenes/{scene_id}/link-prose", response_model=List[Scene]) +async def link_scene_prose( + project_dir: ProjectDep, + scene_id: SceneId, + payload: SceneLinkProseRequest, +) -> List[Scene]: + """Assign a prose-text range to a scene using inline file markers.""" + if payload.start_offset >= payload.end_offset: + raise HTTPException( + status_code=422, detail="start_offset must be less than end_offset" + ) + scene = get_scene(project_dir, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found") + try: + updated = link_prose(project_dir, scene_id, payload) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return [Scene(**s) for s in updated] + + +@router.post("/scenes/batch-link-prose", response_model=List[Scene]) +async def batch_link_scene_prose( + project_dir: ProjectDep, + payload: SceneBatchLinkProseRequest, +) -> List[Scene]: + """Atomically unlink and relink multiple scenes in one scope. + + All assignments are processed in a single pass so touching boundaries + do not get replayed through repeated single-scene edits that can split + freshly inserted markers. + """ + # Unlink requested scenes first + for sid in payload.unlink_ids: + unlink_prose(project_dir, sid) + + # Relink all assigned scenes atomically + assignments = [ + (a.scene_id, a.start_offset, a.end_offset) for a in payload.assignments + ] + try: + updated = relink_scope_prose( + project_dir, + payload.scope_type, + payload.chapter_id, + payload.book_id, + assignments, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return [Scene(**s) for s in updated] + + +@router.post("/scenes/{scene_id}/unlink-prose", response_model=List[Scene]) +async def unlink_scene_prose( + project_dir: ProjectDep, + scene_id: SceneId, +) -> List[Scene]: + """Remove the prose link from a scene, preserving its narrative position. + + Returns all scenes affected in the same prose scope. + """ + scene = get_scene(project_dir, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found") + updated = unlink_prose(project_dir, scene_id) + return [Scene(**s) for s in updated] + + +@router.post("/scenes/reorder-prose", response_model=SceneReorderProseResponse) +async def reorder_scene_prose_route( + project_dir: ProjectDep, + payload: SceneReorderProseRequest, +) -> SceneReorderProseResponse: + """Reorder linked prose blocks and persist marker-aware offsets.""" + try: + updated = reorder_scene_prose(project_dir, payload) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return updated + + +@router.patch("/scenes/{scene_id}/prose-content", response_model=Scene) +async def update_scene_prose_content( + project_dir: ProjectDep, + scene_id: SceneId, + payload: SceneUpdateProseContentRequest, +) -> Scene: + """Replace the text between a scene's inline start/end markers.""" + result = update_prose_content(project_dir, scene_id, payload) + if result is None: + raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found") + return Scene(**result) + + +@router.post("/scenes/detect-boundaries", response_model=SceneDetectBoundariesResponse) +async def detect_boundaries_for_scenes( + project_dir: ProjectDep, + payload: SceneDetectBoundariesRequest, +) -> SceneDetectBoundariesResponse: + """Detect scene boundaries in a prose segment and relink affected scenes.""" + try: + result = await detect_scene_boundaries_and_link( + project_dir=project_dir, + request=payload, + payload={}, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + return SceneDetectBoundariesResponse( + assignments=result["assignments"], + scenes=[Scene(**scene) for scene in result["scenes"]], + ) + + +@router.post("/scenes/{scene_id}/write", response_model=SceneWriteResponse) +async def write_scene_prose( + project_dir: ProjectDep, + scene_id: SceneId, + payload: SceneWriteRequest, +) -> SceneWriteResponse: + """Generate prose for one scene and automatically link generated boundaries.""" + try: + result = await write_scene_and_link( + project_dir=project_dir, + scene_id=scene_id, + request=payload, + payload={}, + ) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + return SceneWriteResponse( + scene=Scene(**result["scene"]), + generated_text=result["generated_text"], + assignments=result["assignments"], + scenes=[Scene(**scene) for scene in result["scenes"]], + ) + + +@router.post("/scenes/auto-link-scope", response_model=AutoLinkScopeResponse) +async def auto_link_saved_scope( + project_dir: ProjectDep, + payload: AutoLinkScopeRequest, +) -> AutoLinkScopeResponse: + """Auto-link a saved prose scope to the scenes that belong to it.""" + try: + result = await auto_link_scope_text( + project_dir=project_dir, + scope_type=payload.scope_type, + chapter_id=payload.chapter_id, + book_id=payload.book_id, + current_text=payload.current_text, + payload={ + "scene_ids": payload.scene_ids, + "start_offset": payload.start_offset, + "end_offset": payload.end_offset, + }, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + return AutoLinkScopeResponse( + assignments=result["assignments"], + scenes=[Scene(**scene) for scene in result["scenes"]], + ) diff --git a/src/augmentedquill/api/v1/sourcebook.py b/src/augmentedquill/api/v1/sourcebook.py index a1c99785..f635dfa9 100644 --- a/src/augmentedquill/api/v1/sourcebook.py +++ b/src/augmentedquill/api/v1/sourcebook.py @@ -94,6 +94,11 @@ async def create_sourcebook_entry( synonyms=entry.synonyms, relations=[r.model_dump() for r in entry.relations], images=entry.images, + origin_date=entry.origin_date, + destination_datetime=entry.destination_datetime, + destination_relative=entry.destination_relative, + creates_new_timeline=entry.creates_new_timeline, + timeline_id=entry.timeline_id, active=project_dir, ) if "error" in created: @@ -128,6 +133,11 @@ async def update_sourcebook_entry( else None ), images=updates.images, + origin_date=updates.origin_date, + destination_datetime=updates.destination_datetime, + destination_relative=updates.destination_relative, + creates_new_timeline=updates.creates_new_timeline, + timeline_id=updates.timeline_id, active=project_dir, ) if "error" in result: diff --git a/src/augmentedquill/api/v1/story_routes/generation_streaming.py b/src/augmentedquill/api/v1/story_routes/generation_streaming.py index b63b2a35..b74d709f 100644 --- a/src/augmentedquill/api/v1/story_routes/generation_streaming.py +++ b/src/augmentedquill/api/v1/story_routes/generation_streaming.py @@ -18,7 +18,7 @@ from augmentedquill.api.v1.dependencies import ProjectDep from augmentedquill.core.config import BASE_DIR, save_story_config -from augmentedquill.core.prompts import get_user_prompt, get_system_message +from augmentedquill.core.prompts import get_system_message, get_user_prompt from augmentedquill.services.llm import llm from augmentedquill.services.story.story_api_prompt_ops import ( resolve_model_runtime, @@ -34,6 +34,7 @@ from augmentedquill.services.story.story_generation_common import ( _restore_summary_for_rewrite, gather_writing_context, + get_scene_context_for_scope, prepare_ai_action_generation, prepare_chapter_summary_generation, prepare_continue_chapter_generation, @@ -45,6 +46,9 @@ stream_collect_and_persist, stream_unified_chat_content, ) +from augmentedquill.services.scenes.scene_generation_service import ( + auto_link_scope_text, +) from augmentedquill.services.exceptions import ServiceError from augmentedquill.services.chat.chat_tool_decorator import WRITING_ROLE from augmentedquill.api.v1.story_routes.common import parse_json_body @@ -582,11 +586,10 @@ def _as_streaming_response( async def api_story_sourcebook_relevance( request: Request, project_dir: ProjectDep ) -> Any: - """Ask the WRITING model which sourcebook entries are relevant. + """Return scene-derived sourcebook entries relevant to the active prose end. - This is a lightweight helper used by the frontend to keep checkboxes - in sync. It is deliberately separate from the prose suggestion call so - that we can run it in the background on every text change. + This runs in the background while the user types, so it must stay + deterministic, cheap, and free of LLM latency. """ try: payload = await parse_json_body(request) @@ -606,121 +609,25 @@ async def api_story_sourcebook_relevance( _, path, pos = get_chapter_locator(chap_id, active=project_dir) current_text = (payload or {}).get("current_text") + scope_text: str | None = None if not isinstance(current_text, str): current_text = ( read_story_content(active=project_dir) if scope == "story" else read_text_or_raise(path) ) - - # gather story and entries - all_entries = [] - try: - from augmentedquill.services.sourcebook.sourcebook_helpers import ( - sourcebook_list_entries, - ) - - all_entries = sourcebook_list_entries(active=project_dir) - except (ImportError, OSError, TypeError, ValueError, RuntimeError): - # if sourcebook is unavailable, just return empty list - return {"relevant": []} - - # prepare prompt using same template as before; model_type WRITING - # build newline-separated list: name plus synonyms in parentheses - entry_lines = [] - for e in all_entries: - parts = [e.get("name", "")] - syns = e.get("synonyms") or [] - if syns: - parts.append(f"({', '.join(syns)})") - entry_lines.append(" ".join(parts)) - text_for_relevance = current_text or "" - paras = [p for p in re.split(r"\n\n+", text_for_relevance) if p.strip()] - recent = "\n\n".join(paras[-3:]) if paras else "" - - prompt = get_user_prompt( - "select_relevant_entries", - language=story.get("language", "en"), - recent_paragraphs=recent, - entries="\n".join(entry_lines), - user_prompt_overrides={}, + scope_text = current_text + elif scope == "chapter" and path is not None: + scope_text = read_text_or_raise(path) + scene_context = get_scene_context_for_scope( + story=story, + scope=scope, + chap_id=(payload or {}).get("chap_id") if scope == "chapter" else None, + current_text=current_text, + scope_text=scope_text, + include_all_scenes=False, ) - - ( - base_url, - api_key, - model_id, - timeout_s, - model_name, - model_overrides, - _model_type, - ) = resolve_model_runtime( - payload=payload, - model_type=WRITING_ROLE, - base_dir=BASE_DIR, - ) - # guarantee at least 120 seconds for background relevance requests to - # reduce spurious ReadTimeouts when using slow reasoning models. - if timeout_s is None or timeout_s < 120: - timeout_s = 120 - - # ask the model synchronously and return the list of names. If the - # request fails (timeout, network error, etc.) we treat it as a - # non‑fatal problem because relevance is a best‑effort feature. Anything - # that isn't immediately useful should just yield an empty result so the - # frontend can continue working without an error dialog. - try: - res = await llm.unified_chat_complete( - caller_id="api.story.sourcebook_relevance", - model_type=WRITING_ROLE, - messages=[ - { - "role": "system", - "content": get_system_message( - "entry_selector", - model_overrides, - language=story.get("language", "en"), - ), - }, - {"role": "user", "content": prompt}, - ], - base_url=base_url, - api_key=api_key, - model_id=model_id, - timeout_s=timeout_s, - model_name=model_name, - ) - raw = res.get("content", "") - # split on newlines/commas and trim - relevant_names = [ - name.strip() for name in re.split(r"[\n,]+", raw) if name.strip() - ] - - relevant_ids = [] - for name in relevant_names: - for e in all_entries: - if e.get("name") == name or name in (e.get("synonyms") or []): - eid = e.get("id") - if eid: - relevant_ids.append(eid) - break - - return {"relevant": relevant_ids} - except (OSError, TypeError, ValueError, RuntimeError) as e: - # log the failure for debugging then return an empty list; the - # front end already ignores errors, but returning a 200 with no - # entries keeps the UI quiet and avoids repeated exception noise. - # We don't have a request/response to log here; just record the - # fact that the background relevance check failed so developers can - # see it when inspecting the logs. - from augmentedquill.services import llm as _llm_module - - _llm_module.llm_logging.add_llm_log( - { - "relevance_error": str(e), - } - ) - return {"relevant": []} + return {"relevant": scene_context.get("sourcebook_ids", [])} except ServiceError as e: raise HTTPException( status_code=e.status_code, @@ -1084,3 +991,62 @@ async def _handler(payload: dict) -> Any: include_exception_text=False, use_raw_exception_detail=True, ) + + +@router.post("/chapters/{chap_id}/rewrite-and-relink") +async def api_chapter_rewrite_and_relink( + request: Request, project_dir: ProjectDep, chap_id: int +) -> Any: + """Atomically rewrite a chapter and auto-link scenes in one transaction. + + This endpoint streams the generated content and persists it, + then performs scene auto-linking on the resulting text. + """ + + async def _handler(payload: dict) -> Any: + """Helper to stream, persist chapter text, then auto-link scenes.""" + prepared = prepare_write_chapter_generation( + payload, chap_id, active=project_dir + ) + + # Collect streamed content, persist to file + collected_content = "" + async for item in stream_collect_and_persist( + lambda: _create_gen_source_pure(prepared), + persist_on_complete=lambda content: prepared["path"].write_text( + content, encoding="utf-8" + ), + ): + # Yield the stream data back to client + yield item + # Extract content from SSE data line if present + if isinstance(item, str) and item.startswith("data: "): + try: + chunk = json.loads(item[6:]) + if isinstance(chunk, dict) and "choices" in chunk: + delta = chunk["choices"][0].get("delta", {}) + if "content" in delta: + collected_content += delta["content"] + except (json.JSONDecodeError, (KeyError, IndexError, TypeError)): + pass + + # After stream completes, auto-link scenes in this chapter + try: + await auto_link_scope_text( + project_dir, + scope_type="chapter", + chapter_id=chap_id, + current_text=collected_content + or prepared["path"].read_text(encoding="utf-8"), + ) + except Exception: + # Log but don't fail the response; scene linking is post-process + pass + + return await _with_parsed_payload( + request, + _handler, + internal_error_prefix="", + include_exception_text=False, + use_raw_exception_detail=True, + ) diff --git a/src/augmentedquill/api/v1/view_state.py b/src/augmentedquill/api/v1/view_state.py new file mode 100644 index 00000000..7e32c8cd --- /dev/null +++ b/src/augmentedquill/api/v1/view_state.py @@ -0,0 +1,44 @@ +# Copyright (C) 2026 StableLlama +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""REST endpoints for per-project view state. + +All routes are scoped under ``/projects/{project_name}/view-state`` and +require a valid, existing project directory resolved via the ``ProjectDep`` +dependency. +""" + +from fastapi import APIRouter + +from augmentedquill.api.v1.dependencies import ProjectDep +from augmentedquill.models.view_state import ViewStatePayload, ViewStateResponse +from augmentedquill.services.projects.view_state_ops import ( + load_view_state, + save_view_state, +) + +router = APIRouter(prefix="/projects/{project_name}", tags=["View State"]) + + +@router.get("/view-state", response_model=ViewStateResponse) +async def get_view_state(project_dir: ProjectDep) -> ViewStateResponse: + """Return the saved view state for the project, with defaults for missing fields.""" + raw = load_view_state(project_dir) + return ViewStateResponse( + ok=True, + view_state=ViewStatePayload(**raw), + ) + + +@router.put("/view-state", response_model=ViewStateResponse) +async def put_view_state( + project_dir: ProjectDep, + payload: ViewStatePayload, +) -> ViewStateResponse: + """Persist view state for the project.""" + save_view_state(project_dir, payload.model_dump()) + return ViewStateResponse(ok=True, view_state=payload) diff --git a/src/augmentedquill/core/config.py b/src/augmentedquill/core/config.py index 35f435e7..4eee2ffb 100644 --- a/src/augmentedquill/core/config.py +++ b/src/augmentedquill/core/config.py @@ -25,6 +25,7 @@ import os import re import sys +import tempfile from pathlib import Path from typing import Any, Dict, Mapping, Optional @@ -52,7 +53,7 @@ LOGS_DIR = DATA_DIR / "logs" STATIC_DIR = BASE_DIR / "static" -CURRENT_SCHEMA_VERSION = 2 +CURRENT_SCHEMA_VERSION = 9 USER_CONFIG_DIR = DATA_DIR / "config" DEFAULT_MACHINE_CONFIG_PATH = ( Path(os.getenv("AUGQ_MACHINE_CONFIG_PATH")) @@ -242,6 +243,26 @@ def load_story_config( defaults = dict(defaults or {}) json_config = load_json_file(path) json_config = _interpolate_env(json_config) + + # Attempt to migrate any older story schema versions before normalizing. + if Path(path).exists(): + try: + from augmentedquill.updates.migrate_story_v5 import migrate_project_v5 + from augmentedquill.updates.migrate_story_v6 import migrate_project_v6 + from augmentedquill.updates.migrate_story_v7 import migrate_project_v7 + from augmentedquill.updates.migrate_story_v8 import migrate_project_v8 + from augmentedquill.updates.migrate_story_v9 import migrate_project_v9 + + migrate_project_v5(Path(path).parent) + migrate_project_v6(Path(path).parent) + migrate_project_v7(Path(path).parent) + migrate_project_v8(Path(path).parent) + migrate_project_v9(Path(path).parent) + json_config = load_json_file(path) + json_config = _interpolate_env(json_config) + except Exception: + pass + merged = _deep_merge(defaults, json_config) return normalize_validate_story_config( merged=merged, @@ -258,9 +279,20 @@ def save_story_config(path: os.PathLike[str] | str, config: Dict[str, Any]) -> N p.parent.mkdir(parents=True) clean_config = clean_story_config_for_disk(config) - - with p.open("w", encoding="utf-8") as f: - json.dump(clean_config, f, indent=2, ensure_ascii=False) + temp_path: Path | None = None + replaced = False + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=p.parent, delete=False + ) as tmp_file: + json.dump(clean_config, tmp_file, indent=2, ensure_ascii=False) + tmp_file.write("\n") + temp_path = Path(tmp_file.name) + try: + os.replace(temp_path, p) + replaced = True + finally: + if temp_path is not None and temp_path.exists() and not replaced: + temp_path.unlink(missing_ok=True) def load_model_presets_config( diff --git a/src/augmentedquill/main.py b/src/augmentedquill/main.py index 8bbeefbd..846089dc 100644 --- a/src/augmentedquill/main.py +++ b/src/augmentedquill/main.py @@ -42,6 +42,9 @@ from augmentedquill.api.v1.debug import router as debug_router # noqa: E402 from augmentedquill.api.v1.sourcebook import router as sourcebook_router # noqa: E402 from augmentedquill.api.v1.search import router as search_router # noqa: E402 +from augmentedquill.api.v1.scenes import router as scenes_router # noqa: E402 +from augmentedquill.api.v1.annotations import router as annotations_router # noqa: E402 +from augmentedquill.api.v1.view_state import router as view_state_router # noqa: E402 def create_app() -> FastAPI: @@ -153,6 +156,9 @@ async def _legacy_project_route_rewrite(request: Request, call_next): api_v1_router.include_router(debug_router) api_v1_router.include_router(sourcebook_router) api_v1_router.include_router(search_router) + api_v1_router.include_router(scenes_router) + api_v1_router.include_router(annotations_router) + api_v1_router.include_router(view_state_router) # JSON REST APIs to serve dynamic data to the frontend (no server-side injection in HTML) api_v1_router.add_api_route( diff --git a/src/augmentedquill/models/chapters.py b/src/augmentedquill/models/chapters.py index 61dc265c..8dc4c17a 100644 --- a/src/augmentedquill/models/chapters.py +++ b/src/augmentedquill/models/chapters.py @@ -14,7 +14,7 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class ChapterSummary(BaseModel): @@ -88,6 +88,8 @@ class ChapterSummaryUpdate(BaseModel): class ChaptersReorderRequest(BaseModel): """Request body for reordering chapters.""" + model_config = ConfigDict(extra="forbid") + chapter_ids: list[int] book_id: str | None = None @@ -95,4 +97,6 @@ class ChaptersReorderRequest(BaseModel): class BooksReorderRequest(BaseModel): """Request body for reordering books.""" + model_config = ConfigDict(extra="forbid") + book_ids: list[str] diff --git a/src/augmentedquill/models/scene.py b/src/augmentedquill/models/scene.py new file mode 100644 index 00000000..f913994f --- /dev/null +++ b/src/augmentedquill/models/scene.py @@ -0,0 +1,574 @@ +# Copyright (C) 2026 StableLlama +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""Pydantic models for scenes – the structural planning layer of a project. + +A Scene represents a narrative unit that may span part of a chapter, an entire +chapter, or remain unlinked from any prose (planning-only). Scenes can also +have Beats – sub-units of action within a scene, each optionally linked to a +specific prose range. + +Prose links record only which content *file* a scene belongs to. The actual +byte positions are stored as HTML-comment markers embedded directly in the +content file (see ``scene_markers.py``) and are computed at read time; they are +never persisted in ``story.json``. +""" + +from __future__ import annotations + +import re +import uuid +from typing import Any, Literal, Optional, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from augmentedquill.models.temporal_utils import normalize_temporal_value + +# Keep a private alias for backward compat within this module +_normalize_scene_temporal_value = normalize_temporal_value + +SceneId: TypeAlias = int + + +def _is_safe_link_identifier(value: str) -> bool: + return bool(re.fullmatch(r"[A-Za-z0-9_.-]+", value or "")) + + +class SceneTagPersonalDatetime(BaseModel): + """Personal age override for a single scene tag. + + ``role`` is ``'active'``, ``'passive'``, or ``'sourcebook'``. + ``ref`` is the character name (for active/passive) or sourcebook entry ID + (for sourcebook). + ``index`` is the 0-based position within the role's list – this allows + the same character to appear multiple times in one scene (e.g. a time + traveller meeting their younger self). + ``personal_age`` is a human-readable age string such as ``'17y'``, + ``'17y 3m'``, ``'5m 12d'``, or ``'30d'``. + """ + + model_config = ConfigDict(extra="forbid") + + role: Literal["active", "passive", "sourcebook"] = Field( + ..., description="Which tag list this override applies to." + ) + ref: str = Field( + ..., description="Character name or sourcebook entry ID for the tag." + ) + index: int = Field( + default=0, + description=( + "0-based position within the chosen tag list. Use this when the same " + "character appears multiple times." + ), + ) + personal_age: str = Field( + ..., description="Human-readable age such as '17y', '17y 3m', or '30d'." + ) + + @field_validator("personal_age", mode="before") + @classmethod + def _normalise(cls, v: object) -> str: + if not isinstance(v, str): + raise ValueError("personal_age must be a string") + stripped = v.strip() + if not stripped: + raise ValueError("personal_age must not be empty") + return stripped + + +class SceneAnnotation(BaseModel): + """Inline annotation metadata mirrored from prose markers.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(..., description="Stable annotation identifier.") + comment: str = Field(..., description="Human-readable annotation comment.") + scope_type: Literal["story", "chapter", "unlinked"] = Field( + default="story", + description="Scope where the annotation lives.", + ) + chapter_id: Optional[str] = Field( + None, + description="Chapter ID when scope_type='chapter'.", + ) + book_id: Optional[str] = Field( + None, + description="Book ID when scope_type='chapter' and the chapter is nested.", + ) + + +class SceneProseLink(BaseModel): + """A link between a scene (or beat) and a specific content file. + + ``scope_type`` distinguishes between: + - ``'story'`` – the main story content file (short-story projects) + - ``'chapter'`` – a specific chapter file (novel / series projects) + - ``'unlinked'`` – internal planning prose for scenes not attached to + story/chapter content + + Only the file identity is persisted. ``start_offset`` and ``end_offset`` + are character positions derived at read time by parsing the inline HTML + comment markers embedded in the content file (see ``scene_markers.py``). + They are populated by the service layer before returning scenes to the API + and are excluded from disk storage. + """ + + model_config = ConfigDict(extra="forbid") + + scope_type: Literal["story", "chapter", "unlinked"] = Field( + ..., + description=( + "Which content scope the scene is linked to: 'story', 'chapter', " + "or internal 'unlinked'." + ), + ) + chapter_id: Optional[str] = Field( + None, + description=( + "Chapter ID when scope_type='chapter'. Leave empty for story or " + "unlinked scope." + ), + ) + book_id: Optional[str] = Field( + None, + description=( + "Book ID when the linked prose belongs to a book chapter. Leave " + "empty for story or unlinked scope." + ), + ) + + @model_validator(mode="after") + def _validate_scope(self) -> "SceneProseLink": + if self.scope_type == "chapter": + if not self.chapter_id or not self.chapter_id.strip(): + raise ValueError("chapter_id is required when scope_type='chapter'") + if not _is_safe_link_identifier(self.chapter_id): + raise ValueError( + "chapter_id must only contain letters, digits, '-', '_', or '.'." + ) + if ( + self.book_id is not None + and self.book_id != "" + and not _is_safe_link_identifier(self.book_id) + ): + raise ValueError( + "book_id must only contain letters, digits, '-', '_', or '.'." + ) + elif self.scope_type == "story": + if self.chapter_id not in (None, ""): + raise ValueError("chapter_id must be omitted when scope_type='story'") + if self.book_id not in (None, ""): + raise ValueError("book_id must be omitted when scope_type='story'") + else: + if self.chapter_id not in (None, ""): + raise ValueError( + "chapter_id must be omitted when scope_type='unlinked'" + ) + if self.book_id not in (None, ""): + raise ValueError("book_id must be omitted when scope_type='unlinked'") + return self + + # Computed at read time from file markers; never written to story.json. + start_offset: Optional[int] = Field( + None, + description="Computed start offset within the linked content file.", + ) + end_offset: Optional[int] = Field( + None, + description="Computed end offset within the linked content file.", + ) + + +class SceneBeat(BaseModel): + """A single beat within a scene – a discrete micro-action or plot step.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Stable beat identifier. Usually generated automatically.", + ) + text: str = Field( + ..., description="Short description of the beat's action or event." + ) + prose_link: Optional[SceneProseLink] = Field( + None, + description="Optional link from this beat to a specific prose range.", + ) + + +class SceneChronologyTime(BaseModel): + """Scene-local timeline point represented as a Temporal ZonedDateTime string.""" + + model_config = ConfigDict(extra="forbid") + + temporal_zoned_datetime: str = Field( + ..., description="Normalized ISO 8601 timestamp for the scene's chronology." + ) + value: str | None = Field( + None, + description=( + "Optional shorthand scene time string accepted by the tool. " + "When provided, it is normalized into temporal_zoned_datetime." + ), + ) + + @model_validator(mode="before") + @classmethod + def _coerce_and_normalize_input(cls, data: object) -> object: + """Accept shorthand scene_time payloads from tools and normalize them.""" + if isinstance(data, str): + return {"temporal_zoned_datetime": _normalize_scene_temporal_value(data)} + + if isinstance(data, dict): + raw = data.get("temporal_zoned_datetime") + if raw is None and "value" in data: + raw = data.get("value") + + if isinstance(raw, str): + normalized = _normalize_scene_temporal_value(raw) + merged = dict(data) + merged["temporal_zoned_datetime"] = normalized + merged.pop("value", None) + return merged + + return data + + +class Scene(BaseModel): + """A narrative scene used for structural story planning. + + Active characters, passive characters, location, and time are stored as + sourcebook entry IDs so the frontend can look them up by reference. + + ``causes`` stores IDs of other scenes that this scene causally precedes. + These are not narrative order indexes; they are dependency constraints that + allow the pinboard to highlight cause/effect relationships. + + ``pinboard_x`` / ``pinboard_y`` store the card's free-form position on the + pinboard canvas, in logical (unscaled) units. + """ + + id: SceneId + summary: str = "" + beats: list[SceneBeat] = [] + active_characters: list[str] = [] + passive_characters: list[str] = [] + sourcebook_entry_ids: list[str] = [] + location: Optional[str] = None + time: Optional[str] = None + scene_time: Optional[SceneChronologyTime] = None + timeline_id: str = "main" + color_tag: Optional[str] = None # hex color, e.g. "#a855f7" + prose_link: Optional[SceneProseLink] = None # used when beats is empty + causes: list[SceneId] = [] # scene IDs this scene causally precedes + pinboard_x: float = 100.0 + pinboard_y: float = 100.0 + status: str = "active" # 'active' | 'inactive' | 'draft' + tag_personal_datetimes: list[SceneTagPersonalDatetime] = Field( + default_factory=list + ) # per-tag personal age overrides (supports duplicate characters) + annotations: list[SceneAnnotation] = Field( + default_factory=list, + description="Optional inline annotations associated with this scene.", + ) + + +# --------------------------------------------------------------------------- +# Request / response bodies +# --------------------------------------------------------------------------- + + +class SceneCreateRequest(BaseModel): + """Payload for creating a new scene.""" + + model_config = ConfigDict(extra="forbid") + + summary: str = Field( + default="", + description=( + "Scene summary / label. Scenes do not have a separate title field; " + "use this field instead. Keep it short, specific, and suitable for a " + "scene card heading." + ), + ) + beats: list[SceneBeat] = Field( + default_factory=list, + description=( + "Optional ordered beats inside the scene. Use when the scene needs a " + "micro-beat breakdown." + ), + ) + active_characters: list[str] = Field( + default_factory=list, + description=( + "Character IDs actively participating in the scene. Use sourcebook/" + "character IDs, not display names, when available." + ), + ) + passive_characters: list[str] = Field( + default_factory=list, + description=( + "Character IDs present but not actively driving the scene. Use " + "sourcebook/character IDs, not display names, when available." + ), + ) + sourcebook_entry_ids: list[str] = Field( + default_factory=list, + description=( + "Sourcebook entry IDs needed to ground the scene's facts, setting, or " + "canon references. Always include relevant sourcebook entries when creating a scene so the scene remains connected to existing world knowledge." + ), + ) + location: Optional[str] = Field( + None, + description="Location identifier or location name for where the scene occurs.", + ) + time: Optional[str] = Field( + None, + description="Human-readable scene time string when a formal chronology is not needed.", + ) + scene_time: Optional[SceneChronologyTime] = Field( + None, + description=( + "Formal timeline position for the scene. Always set this when the scene can be placed on the story timeline; if an exact timestamp is not known, use causes to capture relative chronology. Accepts ISO-like timestamps and normalizes them." + ), + ) + timeline_id: str = Field( + default="main", + description=( + "Explicit timeline identity for this scene. Use 'main' for the " + "primary timeline and stable IDs for branch timelines." + ), + ) + color_tag: Optional[str] = Field( + None, + description="Optional color label for the scene card, usually a hex string.", + ) + prose_link: Optional[SceneProseLink] = Field( + None, + description=( + "Optional prose link showing which content file the scene is linked to " + "prose. Use this when the scene is already anchored to prose." + ), + ) + causes: list[SceneId] = Field( + default_factory=list, + description=( + "IDs of scenes that this scene causally precedes. Use this to " + "record cause/effect relationships without changing narrative order directly." + ), + json_schema_extra={"examples": [[1, 2]]}, + ) + pinboard_x: float = Field( + default=100.0, + description="Pinboard X position in logical canvas units.", + ) + pinboard_y: float = Field( + default=100.0, + description="Pinboard Y position in logical canvas units.", + ) + status: str = Field( + default="active", + description="Scene lifecycle status such as active, inactive, or draft.", + ) + tag_personal_datetimes: list[SceneTagPersonalDatetime] = Field( + default_factory=list, + description=( + "Per-tag personal age overrides used for time-travel or age-specific " + "ordering. Leave empty unless you need those overrides." + ), + ) + + +class SceneUpdateRequest(BaseModel): + """Payload for updating an existing scene.""" + + model_config = ConfigDict(extra="forbid") + + summary: Optional[str] = Field( + None, + description="Replacement scene label/summary. Use this instead of a title.", + ) + beats: Optional[list[SceneBeat]] = Field( + None, + description="Full replacement beat list for the scene.", + ) + active_characters: Optional[list[str]] = Field( + None, + description="Full replacement list of active character IDs.", + ) + passive_characters: Optional[list[str]] = Field( + None, + description="Full replacement list of passive character IDs.", + ) + sourcebook_entry_ids: Optional[list[str]] = Field( + None, + description="Full replacement list of sourcebook entry IDs.", + ) + location: Optional[str] = Field( + None, + description="Replacement location identifier or name.", + ) + time: Optional[str] = Field( + None, + description="Replacement human-readable time string.", + ) + scene_time: Optional[SceneChronologyTime] = Field( + None, + description="Replacement formal chronology timestamp for the scene.", + ) + timeline_id: Optional[str] = Field( + None, + description="Replacement explicit timeline identity for the scene.", + ) + color_tag: Optional[str] = Field( + None, + description="Replacement card color tag.", + ) + prose_link: Optional[SceneProseLink] = Field( + None, + description="Replacement prose link for the scene.", + ) + causes: Optional[list[SceneId]] = Field( + None, + description=( + "Replacement list of scene IDs that this scene causally precedes." + ), + json_schema_extra={"examples": [[1, 2]]}, + ) + pinboard_x: Optional[float] = Field( + None, + description="Replacement pinboard X position.", + ) + pinboard_y: Optional[float] = Field( + None, + description="Replacement pinboard Y position.", + ) + status: Optional[str] = Field( + None, + description="Replacement lifecycle status such as active, inactive, or draft.", + ) + tag_personal_datetimes: Optional[list[SceneTagPersonalDatetime]] = Field( + default=None, + description=( + "Replacement per-tag personal age overrides. Use None to leave the " + "field unchanged, or an explicit list to replace it." + ), + ) # None = no change + + +class SceneLinkProseRequest(BaseModel): + """Payload for assigning a prose-text range to a scene. + + The offsets are UTF-8 character positions within the referenced content + file. Validation against existing scene links happens in the service. + """ + + scope_type: str = "story" # 'story' | 'chapter' | 'unlinked' + chapter_id: Optional[str] = None + book_id: Optional[str] = None + start_offset: int + end_offset: int + + +class SceneBatchLinkProseRequest(BaseModel): + """Batch variant: atomically unlink and relink multiple scenes in one scope. + + ``assignments`` are processed together with ``relink_scope_prose`` so + touching boundaries are never replayed through sequential single-scene + edits that can split freshly inserted markers. + """ + + scope_type: str = "story" + chapter_id: Optional[str] = None + book_id: Optional[str] = None + assignments: list[SceneBoundaryAssignment] = Field(default_factory=list) + unlink_ids: list[SceneId] = Field(default_factory=list) + + +class SceneReorderProseRequest(BaseModel): + """Payload for reordering scenes within a linked prose scope.""" + + source_scene_id: SceneId + target_scene_id: SceneId + place_before: bool = True + + +class SceneReorderProseResponse(BaseModel): + """Result of a prose reorder transaction.""" + + scenes: list[Scene] + scope_type: str + chapter_id: Optional[str] = None + book_id: Optional[str] = None + scope_start: int + scope_end: int + rebuilt_text: str + + +class SceneUpdateProseContentRequest(BaseModel): + """Payload for replacing the prose text between a scene's inline markers.""" + + text: str + + +class SceneBoundaryAssignment(BaseModel): + """One scene-to-prose boundary mapping in absolute offsets.""" + + scene_id: SceneId + start_offset: int + end_offset: int + + +class SceneDetectBoundariesRequest(BaseModel): + """Payload for boundary detection + optional automatic scene relinking.""" + + scope_type: Literal["story", "chapter", "unlinked"] = "chapter" + chapter_id: Optional[str] = None + book_id: Optional[str] = None + scene_ids: list[SceneId] = Field(default_factory=list) + start_offset: int = 0 + end_offset: Optional[int] = None + prose_text: Optional[str] = None + + @field_validator("end_offset") + @classmethod + def _validate_offsets(cls, value: Optional[int], info: Any) -> Optional[int]: + if value is None: + return value + start_offset = int(info.data.get("start_offset", 0)) + if value <= start_offset: + raise ValueError("end_offset must be greater than start_offset") + return value + + +class SceneDetectBoundariesResponse(BaseModel): + """Result of boundary detection + link updates.""" + + assignments: list[SceneBoundaryAssignment] = Field(default_factory=list) + scenes: list[Scene] = Field(default_factory=list) + + +class SceneWriteRequest(BaseModel): + """Payload for generating prose for one scene and linking the result.""" + + scope_type: Optional[Literal["story", "chapter", "unlinked"]] = None + chapter_id: Optional[str] = None + book_id: Optional[str] = None + include_following_scenes: int = 1 + detect_boundaries: bool = True + + +class SceneWriteResponse(BaseModel): + """Result of writing one scene and relinking affected scenes.""" + + scene: Scene + generated_text: str + assignments: list[SceneBoundaryAssignment] = Field(default_factory=list) + scenes: list[Scene] = Field(default_factory=list) diff --git a/src/augmentedquill/models/search.py b/src/augmentedquill/models/search.py index 1bc72129..9bfb173c 100644 --- a/src/augmentedquill/models/search.py +++ b/src/augmentedquill/models/search.py @@ -15,7 +15,7 @@ from enum import Enum from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class SearchScope(str, Enum): @@ -31,6 +31,8 @@ class SearchScope(str, Enum): class SearchOptions(BaseModel): """Parameters controlling what and how to search.""" + model_config = ConfigDict(extra="forbid") + query: str = Field(..., description="Text or pattern to search for") scope: SearchScope = Field( SearchScope.all, description="Which parts of the project to search" @@ -110,6 +112,8 @@ class SearchResponse(BaseModel): class ReplaceAllRequest(BaseModel): """Request to replace all occurrences of a search query.""" + model_config = ConfigDict(extra="forbid") + query: str = Field(..., description="Text or pattern to search for") replacement: str = Field( ..., description="Text to substitute in place of each match" @@ -124,6 +128,8 @@ class ReplaceAllRequest(BaseModel): class ReplaceSingleRequest(BaseModel): """Request to replace one specific match (identified by its ordinal index).""" + model_config = ConfigDict(extra="forbid") + query: str = Field(..., description="Text or pattern to search for") replacement: str = Field( ..., description="Text to substitute in place of the match" diff --git a/src/augmentedquill/models/sourcebook.py b/src/augmentedquill/models/sourcebook.py index d3d07249..620327f2 100644 --- a/src/augmentedquill/models/sourcebook.py +++ b/src/augmentedquill/models/sourcebook.py @@ -16,7 +16,24 @@ from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, field_validator + +from augmentedquill.models.temporal_utils import normalize_temporal_value + + +def _normalize_optional_temporal_text(value: object) -> Optional[str]: + """Normalize optional temporal text input while preserving legacy fallbacks.""" + if value is None: + return None + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return None + try: + return normalize_temporal_value(stripped) + except ValueError: + return stripped class SourcebookRelation(BaseModel): @@ -25,11 +42,20 @@ class SourcebookRelation(BaseModel): target_id: str relation: str direction: Optional[str] = "forward" - start_chapter: Optional[str] = None + start_scene: Optional[int] = None start_book: Optional[str] = None - end_chapter: Optional[str] = None + end_scene: Optional[int] = None end_book: Optional[str] = None + @field_validator("start_scene", "end_scene", mode="before") + @classmethod + def _validate_scene_id(cls, value: object) -> int | None: + if value is None: + return None + if type(value) is int: + return value + raise ValueError("Scene IDs must be integers.") + class SourcebookEntry(BaseModel): """Represents the SourcebookEntry type.""" @@ -42,6 +68,31 @@ class SourcebookEntry(BaseModel): images: List[str] = [] keywords: List[str] = [] relations: List[SourcebookRelation] = [] + origin_date: Optional[str] = ( + None # ISO 8601 birth/creation date for personal timeline age computation + ) + destination_datetime: Optional[str] = ( + None # For Time Travel entries: the absolute destination datetime + ) + destination_relative: Optional[str] = ( + None # For Time Travel entries: human-readable offset e.g. '30 years earlier' + ) + creates_new_timeline: bool = ( + False # For Time Travel entries: whether a new timeline branch is created + ) + timeline_id: Optional[str] = ( + None # For Time Travel branch entries: stable ID of the branch timeline + ) + + @field_validator("origin_date", mode="before") + @classmethod + def _normalise_origin_date(cls, v: object) -> Optional[str]: + return _normalize_optional_temporal_text(v) + + @field_validator("destination_datetime", mode="before") + @classmethod + def _normalise_destination_datetime(cls, v: object) -> Optional[str]: + return _normalize_optional_temporal_text(v) class SourcebookEntryCreate(BaseModel): @@ -53,6 +104,21 @@ class SourcebookEntryCreate(BaseModel): description: str images: List[str] = [] relations: List[SourcebookRelation] = [] + origin_date: Optional[str] = None + destination_datetime: Optional[str] = None + destination_relative: Optional[str] = None + creates_new_timeline: bool = False + timeline_id: Optional[str] = None + + @field_validator("origin_date", mode="before") + @classmethod + def _normalise_origin_date(cls, v: object) -> Optional[str]: + return _normalize_optional_temporal_text(v) + + @field_validator("destination_datetime", mode="before") + @classmethod + def _normalise_destination_datetime(cls, v: object) -> Optional[str]: + return _normalize_optional_temporal_text(v) class SourcebookEntryUpdate(BaseModel): @@ -64,6 +130,21 @@ class SourcebookEntryUpdate(BaseModel): description: Optional[str] = None images: Optional[List[str]] = None relations: Optional[List[SourcebookRelation]] = None + origin_date: Optional[str] = None + destination_datetime: Optional[str] = None + destination_relative: Optional[str] = None + creates_new_timeline: Optional[bool] = None + timeline_id: Optional[str] = None + + @field_validator("origin_date", mode="before") + @classmethod + def _normalise_origin_date(cls, v: object) -> Optional[str]: + return _normalize_optional_temporal_text(v) + + @field_validator("destination_datetime", mode="before") + @classmethod + def _normalise_destination_datetime(cls, v: object) -> Optional[str]: + return _normalize_optional_temporal_text(v) class SourcebookKeywordsRequest(BaseModel): diff --git a/src/augmentedquill/models/story.py b/src/augmentedquill/models/story.py index 7b66b42a..bd3241e6 100644 --- a/src/augmentedquill/models/story.py +++ b/src/augmentedquill/models/story.py @@ -18,6 +18,7 @@ from typing import Any, Optional from pydantic import BaseModel +from augmentedquill.models.scene import Scene # --------------------------------------------------------------------------- # Story payload (returned inside several project endpoints) @@ -64,6 +65,11 @@ class StorySourcebookEntry(BaseModel): images: Optional[list[str]] = None keywords: Optional[list[str]] = None relations: Optional[list[Any]] = None + origin_date: Optional[str] = None + destination_datetime: Optional[str] = None + destination_relative: Optional[str] = None + creates_new_timeline: Optional[bool] = None + timeline_id: Optional[str] = None class StoryPayload(BaseModel): @@ -81,6 +87,7 @@ class StoryPayload(BaseModel): notes: Optional[str] = None private_notes: Optional[str] = None tags: Optional[list[str]] = None + annotations: Optional[list[Any]] = None image_style: Optional[str] = None image_additional_info: Optional[str] = None project_type: Optional[str] = None @@ -89,6 +96,7 @@ class StoryPayload(BaseModel): conflicts: Optional[list[Any]] = None llm_prefs: Optional[StoryLLMPrefs] = None chapters: Optional[list[StoryChapterSummary]] = None + scenes: Optional[list[Scene]] = None # --------------------------------------------------------------------------- diff --git a/src/augmentedquill/models/temporal_utils.py b/src/augmentedquill/models/temporal_utils.py new file mode 100644 index 00000000..25c4aa2f --- /dev/null +++ b/src/augmentedquill/models/temporal_utils.py @@ -0,0 +1,98 @@ +# Copyright (C) 2026 StableLlama +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""Shared temporal value normalization utilities. + +Used by scene and sourcebook models to normalize ISO-like date/time strings +from user or LLM input into a consistent representation. +""" + +from __future__ import annotations + +import re +from datetime import datetime + +_BRACKET_TOKEN_RE = re.compile(r"\[[^\]]+\]") +_DATE_ONLY_RE = re.compile(r"^[+-]?\d{4,}-\d{2}-\d{2}$") +_DATE_TIME_MINUTE_RE = re.compile( + r"^(?P[+-]?\d{4,}-\d{2}-\d{2}[T ]\d{2}:\d{2})(?PZ|[+-]\d{2}:?\d{2})?$" +) +_OFFSET_NO_COLON_RE = re.compile(r"([+-]\d{2})(\d{2})$") +# Matches time-only patterns: HH:MM, HH:MM:SS, with optional timezone or Z suffix +_TIME_ONLY_RE = re.compile( + r"^(?P