From 0d7b7a6f305ddaf0f0864767b148da2bc3d94901 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 6 Aug 2026 02:40:07 +0000 Subject: [PATCH] Add novel-writing app: .NET 10 API, React front end, agent and MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds out the vertical slice for planning and writing a novel. Three front ends — the React UI, an embedded Claude agent, and an MCP stdio server — all go through one REST API, so an edit made from Claude Code and one made in the browser are the same edit. Layout: Domain entities and enums, no dependencies Application services, DTOs, the agent tool-use loop and its 15 tools Infrastructure EF Core 10 + SQLite, Anthropic SDK client Api ASP.NET Core 10 minimal APIs, OpenAPI, ProblemDetails Mcp MCP stdio server, 21 tools over the same REST API Web React 19 + Vite + TanStack Query + Tailwind v4 Data model is Project > Characters / OutlineNodes / Chapters > Scenes, plus agent conversations. The outline is a self-nesting tree so acts, sequences and beats can be arranged however the book wants; scenes carry goal/conflict/outcome because that is what the agent drafts prose from. Notes on a few choices: - Conversation history replays to the model as text only. The agent re-reads current state through its tools rather than trusting a record of edits that may since have changed in the UI. - The user's turn is persisted before the tool loop runs, so a question is recorded even when the model call fails. Turn order uses an explicit sequence column; timestamps tie when a turn completes inside one tick. - Tool failures return is_error results rather than throwing, so the model can read the message and correct itself. MCP tools do the same via CallToolResult, which keeps the API's own message instead of a generic SDK error. - The Anthropic client is constructed lazily. It is injected into the agent service, which also serves read-only endpoints, and those should keep working on an install with no key. Sending without one returns 503, not 400. - DateTimeOffset is stored as UTC ticks. SQLite refuses to ORDER BY the default text form, which every "recently updated first" listing depends on. Tests run against real in-memory SQLite rather than the EF in-memory provider so they exercise the cascade deletes and query translation that actually ship. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw --- .gitignore | 8 + .mcp.json.example | 10 + NovelSoftware.slnx | 12 + README.md | 175 +- .../Endpoints/AgentEndpoints.cs | 40 + .../Endpoints/ChapterEndpoints.cs | 44 + .../Endpoints/CharacterEndpoints.cs | 57 + .../Endpoints/OutlineEndpoints.cs | 49 + .../Endpoints/ProjectEndpoints.cs | 41 + .../Endpoints/SceneEndpoints.cs | 44 + .../NovelSoftware.Api.csproj | 22 + src/NovelSoftware.Api/Program.cs | 78 + .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 9 + src/NovelSoftware.Api/appsettings.json | 22 + .../Agent/AgentContracts.cs | 60 + .../Agent/JsonSchema.cs | 102 + .../Agent/NovelAgentService.cs | 265 +++ .../Agent/NovelAgentToolset.cs | 360 +++ .../AgentNotConfiguredException.cs | 8 + .../Dtos/AgentDtos.cs | 31 + .../Dtos/ChapterDtos.cs | 68 + .../Dtos/CharacterDtos.cs | 86 + .../Dtos/OutlineDtos.cs | 33 + .../Dtos/ProjectDtos.cs | 56 + .../Dtos/SceneDtos.cs | 63 + .../INovelDbContext.cs | 22 + .../NotFoundException.cs | 12 + .../NovelSoftware.Application.csproj | 20 + .../Services/ChapterService.cs | 90 + .../Services/CharacterService.cs | 136 ++ .../Services/OutlineService.cs | 166 ++ .../Services/ProjectService.cs | 87 + .../Services/SceneService.cs | 97 + .../Entities/AgentConversation.cs | 47 + src/NovelSoftware.Domain/Entities/Chapter.cs | 30 + .../Entities/Character.cs | 62 + .../Entities/OutlineNode.cs | 31 + src/NovelSoftware.Domain/Entities/Project.cs | 30 + src/NovelSoftware.Domain/Entities/Scene.cs | 41 + src/NovelSoftware.Domain/Enums.cs | 45 + .../NovelSoftware.Domain.csproj | 9 + .../Anthropic/AnthropicAgentModelClient.cs | 162 ++ .../DependencyInjection.cs | 35 + .../NovelSoftware.Infrastructure.csproj | 24 + .../20260806023249_InitialSchema.Designer.cs | 532 +++++ .../20260806023249_InitialSchema.cs | 339 +++ .../Migrations/NovelDbContextModelSnapshot.cs | 529 +++++ .../Persistence/NovelDbContext.cs | 122 + src/NovelSoftware.Mcp/NovelApiClient.cs | 104 + .../NovelSoftware.Mcp.csproj | 16 + src/NovelSoftware.Mcp/Program.cs | 27 + src/NovelSoftware.Mcp/Tools/CharacterTools.cs | 120 + .../Tools/ManuscriptTools.cs | 136 ++ src/NovelSoftware.Mcp/Tools/OutlineTools.cs | 72 + src/NovelSoftware.Mcp/Tools/ProjectTools.cs | 53 + src/NovelSoftware.Web/.gitignore | 24 + src/NovelSoftware.Web/.oxlintrc.json | 8 + src/NovelSoftware.Web/README.md | 32 + src/NovelSoftware.Web/index.html | 12 + src/NovelSoftware.Web/package-lock.json | 2053 +++++++++++++++++ src/NovelSoftware.Web/package.json | 30 + src/NovelSoftware.Web/public/favicon.svg | 1 + src/NovelSoftware.Web/public/icons.svg | 24 + src/NovelSoftware.Web/src/App.tsx | 26 + src/NovelSoftware.Web/src/api/client.ts | 48 + src/NovelSoftware.Web/src/api/hooks.ts | 231 ++ src/NovelSoftware.Web/src/api/types.ts | 175 ++ src/NovelSoftware.Web/src/components/ui.tsx | 189 ++ src/NovelSoftware.Web/src/index.css | 134 ++ src/NovelSoftware.Web/src/main.tsx | 22 + src/NovelSoftware.Web/src/pages/AgentPage.tsx | 159 ++ .../src/pages/ChapterPage.tsx | 219 ++ .../src/pages/ChaptersPage.tsx | 61 + .../src/pages/CharactersPage.tsx | 258 +++ .../src/pages/OutlinePage.tsx | 139 ++ .../src/pages/OverviewPage.tsx | 137 ++ .../src/pages/ProjectLayout.tsx | 51 + .../src/pages/ProjectsPage.tsx | 136 ++ src/NovelSoftware.Web/src/vite-env.d.ts | 1 + src/NovelSoftware.Web/tsconfig.app.json | 26 + src/NovelSoftware.Web/tsconfig.json | 7 + src/NovelSoftware.Web/tsconfig.node.json | 23 + src/NovelSoftware.Web/vite.config.ts | 18 + .../AnthropicClientTests.cs | 42 + tests/NovelSoftware.Tests/ListingTests.cs | 131 ++ .../NovelAgentServiceTests.cs | 221 ++ .../NovelSoftware.Tests.csproj | 27 + .../OutlineServiceTests.cs | 121 + tests/NovelSoftware.Tests/ProjectDataTests.cs | 152 ++ tests/NovelSoftware.Tests/TestDatabase.cs | 36 + 91 files changed, 9935 insertions(+), 1 deletion(-) create mode 100644 .mcp.json.example create mode 100644 NovelSoftware.slnx create mode 100644 src/NovelSoftware.Api/Endpoints/AgentEndpoints.cs create mode 100644 src/NovelSoftware.Api/Endpoints/ChapterEndpoints.cs create mode 100644 src/NovelSoftware.Api/Endpoints/CharacterEndpoints.cs create mode 100644 src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs create mode 100644 src/NovelSoftware.Api/Endpoints/ProjectEndpoints.cs create mode 100644 src/NovelSoftware.Api/Endpoints/SceneEndpoints.cs create mode 100644 src/NovelSoftware.Api/NovelSoftware.Api.csproj create mode 100644 src/NovelSoftware.Api/Program.cs create mode 100644 src/NovelSoftware.Api/Properties/launchSettings.json create mode 100644 src/NovelSoftware.Api/appsettings.Development.json create mode 100644 src/NovelSoftware.Api/appsettings.json create mode 100644 src/NovelSoftware.Application/Agent/AgentContracts.cs create mode 100644 src/NovelSoftware.Application/Agent/JsonSchema.cs create mode 100644 src/NovelSoftware.Application/Agent/NovelAgentService.cs create mode 100644 src/NovelSoftware.Application/Agent/NovelAgentToolset.cs create mode 100644 src/NovelSoftware.Application/AgentNotConfiguredException.cs create mode 100644 src/NovelSoftware.Application/Dtos/AgentDtos.cs create mode 100644 src/NovelSoftware.Application/Dtos/ChapterDtos.cs create mode 100644 src/NovelSoftware.Application/Dtos/CharacterDtos.cs create mode 100644 src/NovelSoftware.Application/Dtos/OutlineDtos.cs create mode 100644 src/NovelSoftware.Application/Dtos/ProjectDtos.cs create mode 100644 src/NovelSoftware.Application/Dtos/SceneDtos.cs create mode 100644 src/NovelSoftware.Application/INovelDbContext.cs create mode 100644 src/NovelSoftware.Application/NotFoundException.cs create mode 100644 src/NovelSoftware.Application/NovelSoftware.Application.csproj create mode 100644 src/NovelSoftware.Application/Services/ChapterService.cs create mode 100644 src/NovelSoftware.Application/Services/CharacterService.cs create mode 100644 src/NovelSoftware.Application/Services/OutlineService.cs create mode 100644 src/NovelSoftware.Application/Services/ProjectService.cs create mode 100644 src/NovelSoftware.Application/Services/SceneService.cs create mode 100644 src/NovelSoftware.Domain/Entities/AgentConversation.cs create mode 100644 src/NovelSoftware.Domain/Entities/Chapter.cs create mode 100644 src/NovelSoftware.Domain/Entities/Character.cs create mode 100644 src/NovelSoftware.Domain/Entities/OutlineNode.cs create mode 100644 src/NovelSoftware.Domain/Entities/Project.cs create mode 100644 src/NovelSoftware.Domain/Entities/Scene.cs create mode 100644 src/NovelSoftware.Domain/Enums.cs create mode 100644 src/NovelSoftware.Domain/NovelSoftware.Domain.csproj create mode 100644 src/NovelSoftware.Infrastructure/Anthropic/AnthropicAgentModelClient.cs create mode 100644 src/NovelSoftware.Infrastructure/DependencyInjection.cs create mode 100644 src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj create mode 100644 src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.Designer.cs create mode 100644 src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.cs create mode 100644 src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs create mode 100644 src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs create mode 100644 src/NovelSoftware.Mcp/NovelApiClient.cs create mode 100644 src/NovelSoftware.Mcp/NovelSoftware.Mcp.csproj create mode 100644 src/NovelSoftware.Mcp/Program.cs create mode 100644 src/NovelSoftware.Mcp/Tools/CharacterTools.cs create mode 100644 src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs create mode 100644 src/NovelSoftware.Mcp/Tools/OutlineTools.cs create mode 100644 src/NovelSoftware.Mcp/Tools/ProjectTools.cs create mode 100644 src/NovelSoftware.Web/.gitignore create mode 100644 src/NovelSoftware.Web/.oxlintrc.json create mode 100644 src/NovelSoftware.Web/README.md create mode 100644 src/NovelSoftware.Web/index.html create mode 100644 src/NovelSoftware.Web/package-lock.json create mode 100644 src/NovelSoftware.Web/package.json create mode 100644 src/NovelSoftware.Web/public/favicon.svg create mode 100644 src/NovelSoftware.Web/public/icons.svg create mode 100644 src/NovelSoftware.Web/src/App.tsx create mode 100644 src/NovelSoftware.Web/src/api/client.ts create mode 100644 src/NovelSoftware.Web/src/api/hooks.ts create mode 100644 src/NovelSoftware.Web/src/api/types.ts create mode 100644 src/NovelSoftware.Web/src/components/ui.tsx create mode 100644 src/NovelSoftware.Web/src/index.css create mode 100644 src/NovelSoftware.Web/src/main.tsx create mode 100644 src/NovelSoftware.Web/src/pages/AgentPage.tsx create mode 100644 src/NovelSoftware.Web/src/pages/ChapterPage.tsx create mode 100644 src/NovelSoftware.Web/src/pages/ChaptersPage.tsx create mode 100644 src/NovelSoftware.Web/src/pages/CharactersPage.tsx create mode 100644 src/NovelSoftware.Web/src/pages/OutlinePage.tsx create mode 100644 src/NovelSoftware.Web/src/pages/OverviewPage.tsx create mode 100644 src/NovelSoftware.Web/src/pages/ProjectLayout.tsx create mode 100644 src/NovelSoftware.Web/src/pages/ProjectsPage.tsx create mode 100644 src/NovelSoftware.Web/src/vite-env.d.ts create mode 100644 src/NovelSoftware.Web/tsconfig.app.json create mode 100644 src/NovelSoftware.Web/tsconfig.json create mode 100644 src/NovelSoftware.Web/tsconfig.node.json create mode 100644 src/NovelSoftware.Web/vite.config.ts create mode 100644 tests/NovelSoftware.Tests/AnthropicClientTests.cs create mode 100644 tests/NovelSoftware.Tests/ListingTests.cs create mode 100644 tests/NovelSoftware.Tests/NovelAgentServiceTests.cs create mode 100644 tests/NovelSoftware.Tests/NovelSoftware.Tests.csproj create mode 100644 tests/NovelSoftware.Tests/OutlineServiceTests.cs create mode 100644 tests/NovelSoftware.Tests/ProjectDataTests.cs create mode 100644 tests/NovelSoftware.Tests/TestDatabase.cs diff --git a/.gitignore b/.gitignore index d5a18de..aefa0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,11 @@ FodyWeavers.xsd *.msix *.msm *.msp + +## Novel Software +node_modules/ +dist/ +*.db +*.db-shm +*.db-wal +mcp-server/ diff --git a/.mcp.json.example b/.mcp.json.example new file mode 100644 index 0000000..e526305 --- /dev/null +++ b/.mcp.json.example @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "novel-software": { + "command": "./mcp-server/NovelSoftware.Mcp", + "env": { + "NOVELSOFTWARE_API_URL": "http://localhost:5080" + } + } + } +} diff --git a/NovelSoftware.slnx b/NovelSoftware.slnx new file mode 100644 index 0000000..d2c7a3f --- /dev/null +++ b/NovelSoftware.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/README.md b/README.md index 1a92238..8115a19 100644 --- a/README.md +++ b/README.md @@ -1 +1,174 @@ -# novel-software \ No newline at end of file +# Novel Software + +Software for planning and writing a novel. You outline the book, keep character +dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent +embedded in the app that can read and edit the same data you can, and an MCP server that +exposes that data to Claude Code, Claude Desktop, or any other MCP client. + +The point of the three-way arrangement is that there is exactly one source of truth. The +React UI, the embedded agent, and the MCP server all go through the same REST API, so an +edit made from a chat in Claude Code and an edit made by typing in the browser are the +same edit. + +## Stack + +| Piece | Built with | +|---|---| +| `NovelSoftware.Api` | ASP.NET Core 10 minimal APIs, OpenAPI | +| `NovelSoftware.Application` | Services, DTOs, the agent tool-use loop | +| `NovelSoftware.Domain` | Entities and enums, no dependencies | +| `NovelSoftware.Infrastructure` | EF Core 10 + SQLite, Anthropic SDK client | +| `NovelSoftware.Mcp` | MCP stdio server (`ModelContextProtocol`) | +| `NovelSoftware.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 | + +## Running it + +Prerequisites: .NET 10 SDK and Node 20+. + +```bash +# 1. API — creates and migrates novel.db on first run, listens on :5080 +ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/NovelSoftware.Api + +# 2. Web — dev server on :5173, proxies /api to :5080 +cd src/NovelSoftware.Web && npm install && npm run dev +``` + +Open http://localhost:5173. + +The app is fully usable without an Anthropic key — only the Agent tab needs one. To turn +the agent on: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +Without it, agent endpoints return `503 Agent unavailable` with an explanatory message +and everything else keeps working. + +### Tests + +```bash +dotnet test # 31 tests +cd src/NovelSoftware.Web && npm run build # typecheck + bundle +``` + +Tests run against real in-memory SQLite rather than the EF in-memory provider, so they +exercise the cascade deletes and query translation the app actually ships with. + +## Configuration + +`src/NovelSoftware.Api/appsettings.json`: + +```jsonc +{ + "ConnectionStrings": { "Novel": "Data Source=novel.db" }, + "Cors": { "Origins": [ "http://localhost:5173" ] }, + "Agent": { + "Model": "claude-opus-5", + "MaxTokens": 16000, + "Effort": "high", // low | medium | high | max + "MaxIterations": 12 // tool-call ceiling per user turn + } +} +``` + +The API key is read from `ANTHROPIC_API_KEY` or, if you prefer, `Agent:ApiKey` — keep it +out of `appsettings.json` and use user-secrets or the environment. + +## The data model + +``` +Project ──┬── Character ── CharacterRelationship + ├── OutlineNode (self-nesting: Part > Act > Sequence > Beat) + ├── Chapter ── Scene (goal / conflict / outcome, prose, word count) + └── AgentConversation ── AgentMessage +``` + +The outline tree is deliberately loose — nest acts under parts, beats under sequences, or +keep a flat list of beats. An outline node can link to the chapter that realises it. + +Scenes carry the goal/conflict/outcome trio because that is the unit the agent works from +when turning an outline into prose. Word counts are recomputed on every save. + +## The embedded agent + +`NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools +Claude asks for, feeds every result back in a single user turn, and repeats until Claude +stops asking. It has 15 tools covering the brief, characters, the outline tree, chapters +and scenes — all of them going through the same application services the REST API uses. + +A few deliberate choices worth knowing about: + +- **Conversation history replays as text only.** Tool calls are not replayed into the + transcript. The agent re-reads current state through its tools instead, which is more + reliable than trusting a record of edits that may since have changed in the UI. +- **The user's turn is persisted before the loop runs**, so a question is recorded even if + the model call fails. +- **Tool failures come back as `is_error` results**, not exceptions — the model reads the + message and corrects itself. +- **`MaxIterations` caps tool calls per turn.** On hitting it the agent says so rather + than silently truncating. +- **The system prompt is cached** (`cache_control: ephemeral`), so every turn after the + first reads it back at a fraction of the input price. + +## The MCP server + +A stdio MCP server exposing 21 tools over the same REST API. It holds no domain logic of +its own — it is a second front end, not a second implementation. + +Build it, then point your MCP client at the produced binary: + +```bash +dotnet publish src/NovelSoftware.Mcp -c Release -o ./mcp-server +``` + +`.mcp.json` (or Claude Desktop's config): + +```jsonc +{ + "mcpServers": { + "novel-software": { + "command": "/absolute/path/to/mcp-server/NovelSoftware.Mcp", + "env": { "NOVELSOFTWARE_API_URL": "http://localhost:5080" } + } + } +} +``` + +The API must be running. If it is not, the tools say so in a message the model can act on +rather than failing opaquely. + +## API surface + +`GET /api/health`, plus: + +| Resource | Routes | +|---|---| +| Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` | +| Characters | `GET\|POST /api/projects/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` | +| Outline | `GET\|POST /api/projects/{id}/outline`, `GET\|PATCH\|DELETE /api/outline/{id}`, `POST /api/outline/{id}/move` | +| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` | +| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` | +| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` | + +`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. +Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the +OpenAPI document is at `/openapi/v1.json`. + +## Known issues + +- `react-router-dom` 7.18.2 carries [GHSA-qwww-vcr4-c8h2](https://github.com/advisories/GHSA-qwww-vcr4-c8h2) + (CSRF bypass in RSC mode). No patched release exists yet, and every version below the + affected range carries 14 worse advisories. This app is a client-only SPA and does not + use RSC mode, so the advisory does not apply — but `npm audit` will flag it until a fix + ships. Upgrade when one does. + +## Where this could go next + +The vertical slice is complete but thin in places. The obvious next steps: + +- Stream agent responses over SSE instead of returning the finished turn. +- Drag-and-drop reordering in the outline (the `move` endpoint is already there). +- A manuscript export (Markdown, DOCX) built from chapters and scenes in order. +- Revision history for scene prose. +- Authentication, if this is ever going to run anywhere but localhost. diff --git a/src/NovelSoftware.Api/Endpoints/AgentEndpoints.cs b/src/NovelSoftware.Api/Endpoints/AgentEndpoints.cs new file mode 100644 index 0000000..f268191 --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/AgentEndpoints.cs @@ -0,0 +1,40 @@ +using NovelSoftware.Application.Agent; +using NovelSoftware.Application.Dtos; + +namespace NovelSoftware.Api.Endpoints; + +public static class AgentEndpoints +{ + public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app) + { + var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent"); + + projectScoped.MapGet("/conversations", async ( + Guid projectId, NovelAgentService agent, CancellationToken ct) => + Results.Ok(await agent.ListConversationsAsync(projectId, ct))) + .WithSummary("List the project's agent conversations."); + + projectScoped.MapPost("/messages", async ( + Guid projectId, + SendAgentMessageRequest request, + NovelAgentService agent, + CancellationToken ct) => + Results.Ok(await agent.SendMessageAsync(projectId, request, ct))) + .WithSummary("Send a message to the writing agent and run it to completion."); + + var conversations = app.MapGroup("/api/conversations").WithTags("Agent"); + + conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) => + Results.Ok(await agent.GetConversationAsync(id, ct))) + .WithSummary("Read a conversation's full transcript."); + + conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) => + { + await agent.DeleteConversationAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete a conversation."); + + return app; + } +} diff --git a/src/NovelSoftware.Api/Endpoints/ChapterEndpoints.cs b/src/NovelSoftware.Api/Endpoints/ChapterEndpoints.cs new file mode 100644 index 0000000..0140c44 --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/ChapterEndpoints.cs @@ -0,0 +1,44 @@ +using NovelSoftware.Application.Dtos; +using NovelSoftware.Application.Services; + +namespace NovelSoftware.Api.Endpoints; + +public static class ChapterEndpoints +{ + public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app) + { + var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters"); + + projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) => + Results.Ok(await service.ListAsync(projectId, ct))) + .WithSummary("List a project's chapters in manuscript order."); + + projectScoped.MapPost("/", async ( + Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) => + { + var created = await service.CreateAsync(projectId, request, ct); + return Results.Created($"/api/chapters/{created.Id}", created); + }) + .WithSummary("Add a chapter."); + + var chapters = app.MapGroup("/api/chapters").WithTags("Chapters"); + + chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => + Results.Ok(await service.GetAsync(id, ct))) + .WithSummary("Read a chapter with all of its scenes."); + + chapters.MapPatch("/{id:guid}", async ( + Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => + Results.Ok(await service.UpdateAsync(id, request, ct))) + .WithSummary("Update a chapter."); + + chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => + { + await service.DeleteAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete a chapter and its scenes."); + + return app; + } +} diff --git a/src/NovelSoftware.Api/Endpoints/CharacterEndpoints.cs b/src/NovelSoftware.Api/Endpoints/CharacterEndpoints.cs new file mode 100644 index 0000000..0994eb2 --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/CharacterEndpoints.cs @@ -0,0 +1,57 @@ +using NovelSoftware.Application.Dtos; +using NovelSoftware.Application.Services; + +namespace NovelSoftware.Api.Endpoints; + +public static class CharacterEndpoints +{ + public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app) + { + var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters"); + + projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) => + Results.Ok(await service.ListAsync(projectId, ct))) + .WithSummary("List a project's character dossiers."); + + projectScoped.MapPost("/", async ( + Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) => + { + var created = await service.CreateAsync(projectId, request, ct); + return Results.Created($"/api/characters/{created.Id}", created); + }) + .WithSummary("Add a character dossier."); + + var characters = app.MapGroup("/api/characters").WithTags("Characters"); + + characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => + Results.Ok(await service.GetAsync(id, ct))) + .WithSummary("Read a character dossier."); + + characters.MapPatch("/{id:guid}", async ( + Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) => + Results.Ok(await service.UpdateAsync(id, request, ct))) + .WithSummary("Update a character dossier."); + + characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => + { + await service.DeleteAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete a character."); + + characters.MapPost("/{id:guid}/relationships", async ( + Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => + Results.Ok(await service.AddRelationshipAsync(id, request, ct))) + .WithSummary("Relate this character to another in the same project."); + + characters.MapDelete("/relationships/{relationshipId:guid}", async ( + Guid relationshipId, CharacterService service, CancellationToken ct) => + { + await service.RemoveRelationshipAsync(relationshipId, ct); + return Results.NoContent(); + }) + .WithSummary("Remove a relationship."); + + return app; + } +} diff --git a/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs b/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs new file mode 100644 index 0000000..6f567a9 --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs @@ -0,0 +1,49 @@ +using NovelSoftware.Application.Dtos; +using NovelSoftware.Application.Services; + +namespace NovelSoftware.Api.Endpoints; + +public static class OutlineEndpoints +{ + public static IEndpointRouteBuilder MapOutlineEndpoints(this IEndpointRouteBuilder app) + { + var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/outline").WithTags("Outline"); + + projectScoped.MapGet("/", async (Guid projectId, OutlineService service, CancellationToken ct) => + Results.Ok(await service.GetTreeAsync(projectId, ct))) + .WithSummary("Read the project's outline as a nested tree."); + + projectScoped.MapPost("/", async ( + Guid projectId, CreateOutlineNodeRequest request, OutlineService service, CancellationToken ct) => + { + var created = await service.CreateAsync(projectId, request, ct); + return Results.Created($"/api/outline/{created.Id}", created); + }) + .WithSummary("Add an outline node."); + + var nodes = app.MapGroup("/api/outline").WithTags("Outline"); + + nodes.MapGet("/{id:guid}", async (Guid id, OutlineService service, CancellationToken ct) => + Results.Ok(await service.GetAsync(id, ct))) + .WithSummary("Read one outline node and its subtree."); + + nodes.MapPatch("/{id:guid}", async ( + Guid id, UpdateOutlineNodeRequest request, OutlineService service, CancellationToken ct) => + Results.Ok(await service.UpdateAsync(id, request, ct))) + .WithSummary("Update an outline node."); + + nodes.MapPost("/{id:guid}/move", async ( + Guid id, MoveOutlineNodeRequest request, OutlineService service, CancellationToken ct) => + Results.Ok(await service.MoveAsync(id, request, ct))) + .WithSummary("Reparent or reorder an outline node."); + + nodes.MapDelete("/{id:guid}", async (Guid id, OutlineService service, CancellationToken ct) => + { + await service.DeleteAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete an outline node and everything beneath it."); + + return app; + } +} diff --git a/src/NovelSoftware.Api/Endpoints/ProjectEndpoints.cs b/src/NovelSoftware.Api/Endpoints/ProjectEndpoints.cs new file mode 100644 index 0000000..e093d4b --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/ProjectEndpoints.cs @@ -0,0 +1,41 @@ +using NovelSoftware.Application.Dtos; +using NovelSoftware.Application.Services; + +namespace NovelSoftware.Api.Endpoints; + +public static class ProjectEndpoints +{ + public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/projects").WithTags("Projects"); + + group.MapGet("/", async (ProjectService service, CancellationToken ct) => + Results.Ok(await service.ListAsync(ct))) + .WithSummary("List all novel projects."); + + group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => + Results.Ok(await service.GetAsync(id, ct))) + .WithSummary("Read a project's brief."); + + group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) => + { + var created = await service.CreateAsync(request, ct); + return Results.Created($"/api/projects/{created.Id}", created); + }) + .WithSummary("Create a novel project."); + + group.MapPatch("/{id:guid}", async ( + Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) => + Results.Ok(await service.UpdateAsync(id, request, ct))) + .WithSummary("Update a project's brief."); + + group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => + { + await service.DeleteAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete a project and everything in it."); + + return app; + } +} diff --git a/src/NovelSoftware.Api/Endpoints/SceneEndpoints.cs b/src/NovelSoftware.Api/Endpoints/SceneEndpoints.cs new file mode 100644 index 0000000..13ca5a3 --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/SceneEndpoints.cs @@ -0,0 +1,44 @@ +using NovelSoftware.Application.Dtos; +using NovelSoftware.Application.Services; + +namespace NovelSoftware.Api.Endpoints; + +public static class SceneEndpoints +{ + public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app) + { + var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes"); + + chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) => + Results.Ok(await service.ListAsync(chapterId, ct))) + .WithSummary("List a chapter's scenes in order."); + + chapterScoped.MapPost("/", async ( + Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) => + { + var created = await service.CreateAsync(chapterId, request, ct); + return Results.Created($"/api/scenes/{created.Id}", created); + }) + .WithSummary("Add a scene to a chapter."); + + var scenes = app.MapGroup("/api/scenes").WithTags("Scenes"); + + scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => + Results.Ok(await service.GetAsync(id, ct))) + .WithSummary("Read a scene, including its prose."); + + scenes.MapPatch("/{id:guid}", async ( + Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) => + Results.Ok(await service.UpdateAsync(id, request, ct))) + .WithSummary("Update a scene. Sending prose recomputes the word count."); + + scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => + { + await service.DeleteAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete a scene."); + + return app; + } +} diff --git a/src/NovelSoftware.Api/NovelSoftware.Api.csproj b/src/NovelSoftware.Api/NovelSoftware.Api.csproj new file mode 100644 index 0000000..0f65328 --- /dev/null +++ b/src/NovelSoftware.Api/NovelSoftware.Api.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + net10.0 + enable + enable + + + diff --git a/src/NovelSoftware.Api/Program.cs b/src/NovelSoftware.Api/Program.cs new file mode 100644 index 0000000..f1c39dd --- /dev/null +++ b/src/NovelSoftware.Api/Program.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Api.Endpoints; +using NovelSoftware.Application; +using NovelSoftware.Infrastructure; +using NovelSoftware.Infrastructure.Persistence; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddNovelSoftware(builder.Configuration); +builder.Services.AddOpenApi(); +builder.Services.AddProblemDetails(); + +// Enums travel as their names, so the React client and the MCP server both read +// "Protagonist" rather than an ordinal that shifts whenever the enum is reordered. +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + +var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get() + ?? ["http://localhost:5173"]; + +builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy + .WithOrigins(corsOrigins) + .AllowAnyHeader() + .AllowAnyMethod())); + +var app = builder.Build(); + +// Local-first tool: bring the SQLite file up to date on boot rather than making the +// writer run a migration command before they can open the app. +using (var scope = app.Services.CreateScope()) +{ + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); +} + +app.UseExceptionHandler(handler => handler.Run(async context => +{ + var exception = context.Features.Get()?.Error; + + var (status, title) = exception switch + { + NotFoundException => (StatusCodes.Status404NotFound, "Not found"), + AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"), + ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"), + _ => (StatusCodes.Status500InternalServerError, "Unexpected error") + }; + + if (status == StatusCodes.Status500InternalServerError) + { + app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path); + } + + await Results + .Problem(title: title, detail: exception?.Message, statusCode: status) + .ExecuteAsync(context); +})); + +app.UseCors(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health"); + +app.MapProjectEndpoints() + .MapCharacterEndpoints() + .MapOutlineEndpoints() + .MapChapterEndpoints() + .MapSceneEndpoints() + .MapAgentEndpoints(); + +app.Run(); + +/// Exposed so the tests can spin the API up with WebApplicationFactory. +public partial class Program; diff --git a/src/NovelSoftware.Api/Properties/launchSettings.json b/src/NovelSoftware.Api/Properties/launchSettings.json new file mode 100644 index 0000000..74e737a --- /dev/null +++ b/src/NovelSoftware.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5266", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7123;http://localhost:5266", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/NovelSoftware.Api/appsettings.Development.json b/src/NovelSoftware.Api/appsettings.Development.json new file mode 100644 index 0000000..36ce91e --- /dev/null +++ b/src/NovelSoftware.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "NovelSoftware": "Debug", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/NovelSoftware.Api/appsettings.json b/src/NovelSoftware.Api/appsettings.json new file mode 100644 index 0000000..72510ac --- /dev/null +++ b/src/NovelSoftware.Api/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Novel": "Data Source=novel.db" + }, + "Cors": { + "Origins": [ "http://localhost:5173" ] + }, + "Agent": { + "Model": "claude-opus-5", + "MaxTokens": 16000, + "Effort": "high", + "MaxIterations": 12 + } +} diff --git a/src/NovelSoftware.Application/Agent/AgentContracts.cs b/src/NovelSoftware.Application/Agent/AgentContracts.cs new file mode 100644 index 0000000..6018d38 --- /dev/null +++ b/src/NovelSoftware.Application/Agent/AgentContracts.cs @@ -0,0 +1,60 @@ +using System.Text.Json; + +namespace NovelSoftware.Application.Agent; + +/// A tool the model may call, described in the shape the Messages API expects. +public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema); + +/// One content block in a model turn. +public abstract record AgentContentBlock; + +public record AgentTextBlock(string Text) : AgentContentBlock; + +public record AgentToolUseBlock(string Id, string Name, JsonElement Input) : AgentContentBlock; + +public record AgentToolResultBlock(string ToolUseId, string Content, bool IsError = false) : AgentContentBlock; + +/// A full turn in the conversation sent to or received from the model. +public record AgentChatMessage(string Role, IReadOnlyList Content) +{ + public static AgentChatMessage User(params AgentContentBlock[] content) => new("user", content); + public static AgentChatMessage Assistant(IReadOnlyList content) => new("assistant", content); +} + +public record AgentModelResponse(IReadOnlyList Content, string? StopReason); + +/// +/// The model-facing seam. Infrastructure implements this against the Anthropic SDK; +/// tests substitute a scripted stand-in so the agent loop can be exercised offline. +/// +public interface IAgentModelClient +{ + Task CompleteAsync( + string systemPrompt, + IReadOnlyList messages, + IReadOnlyList tools, + CancellationToken ct = default); +} + +/// Configuration for the embedded writing agent. +public class AgentOptions +{ + public const string SectionName = "Agent"; + + /// Anthropic model id. Defaults to the current Opus. + public string Model { get; set; } = "claude-opus-5"; + + public int MaxTokens { get; set; } = 16000; + + /// Thinking depth: low | medium | high | xhigh | max. + public string Effort { get; set; } = "high"; + + /// + /// Ceiling on model round-trips per user turn. Each tool call costs one; without a + /// cap a confused model could loop indefinitely. + /// + public int MaxIterations { get; set; } = 12; + + /// Falls back to the ANTHROPIC_API_KEY environment variable when unset. + public string? ApiKey { get; set; } +} diff --git a/src/NovelSoftware.Application/Agent/JsonSchema.cs b/src/NovelSoftware.Application/Agent/JsonSchema.cs new file mode 100644 index 0000000..3c7a181 --- /dev/null +++ b/src/NovelSoftware.Application/Agent/JsonSchema.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace NovelSoftware.Application.Agent; + +/// +/// Small builder for the JSON Schema objects tool definitions need. Hand-writing these +/// as string literals is where tool definitions usually rot, so build them structurally. +/// +public sealed class JsonSchemaBuilder +{ + private readonly JsonObject _properties = []; + private readonly JsonArray _required = []; + + public JsonSchemaBuilder Str(string name, string description, bool required = false) => + Add(name, "string", description, required); + + public JsonSchemaBuilder Int(string name, string description, bool required = false) => + Add(name, "integer", description, required); + + public JsonSchemaBuilder Bool(string name, string description, bool required = false) => + Add(name, "boolean", description, required); + + public JsonSchemaBuilder Enum(string name, string description, IEnumerable values, bool required = false) + { + var node = new JsonObject + { + ["type"] = "string", + ["description"] = description, + ["enum"] = new JsonArray([.. values.Select(v => JsonValue.Create(v))]) + }; + + _properties[name] = node; + if (required) + { + _required.Add(name); + } + + return this; + } + + private JsonSchemaBuilder Add(string name, string type, string description, bool required) + { + _properties[name] = new JsonObject { ["type"] = type, ["description"] = description }; + if (required) + { + _required.Add(name); + } + + return this; + } + + public JsonElement Build() + { + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = _properties, + ["required"] = _required + }; + + return JsonSerializer.Deserialize(schema.ToJsonString()); + } +} + +/// Lenient readers for tool input, which arrives as untyped JSON. +public static class JsonInput +{ + public static string? String(JsonElement input, string name) => + input.ValueKind == JsonValueKind.Object + && input.TryGetProperty(name, out var value) + && value.ValueKind is JsonValueKind.String + ? value.GetString() + : null; + + public static string RequiredString(JsonElement input, string name) => + String(input, name) ?? throw new ArgumentException($"Missing required argument '{name}'."); + + public static Guid? Guid(JsonElement input, string name) => + System.Guid.TryParse(String(input, name), out var id) ? id : null; + + public static Guid RequiredGuid(JsonElement input, string name) => + Guid(input, name) ?? throw new ArgumentException($"Missing or malformed id argument '{name}'."); + + public static int? Int(JsonElement input, string name) + { + if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.Number when value.TryGetInt32(out var n) => n, + JsonValueKind.String when int.TryParse(value.GetString(), out var n) => n, + _ => null + }; + } + + public static TEnum? Enum(JsonElement input, string name) where TEnum : struct, System.Enum => + System.Enum.TryParse(String(input, name), ignoreCase: true, out var parsed) ? parsed : null; +} diff --git a/src/NovelSoftware.Application/Agent/NovelAgentService.cs b/src/NovelSoftware.Application/Agent/NovelAgentService.cs new file mode 100644 index 0000000..f23cb19 --- /dev/null +++ b/src/NovelSoftware.Application/Agent/NovelAgentService.cs @@ -0,0 +1,265 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Domain; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Agent; + +/// +/// The embedded writing agent. Runs the tool-use loop against the model, persists the +/// conversation, and returns the finished turn together with a record of what it changed. +/// +public class NovelAgentService( + INovelDbContext db, + IAgentModelClient model, + NovelAgentToolset toolset, + IOptions options, + ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + + private readonly AgentOptions _options = options.Value; + + public async Task> ListConversationsAsync( + Guid projectId, CancellationToken ct = default) => + await db.Conversations + .Where(c => c.ProjectId == projectId) + .OrderByDescending(c => c.UpdatedAt) + .Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) + .ToListAsync(ct); + + public async Task GetConversationAsync(Guid conversationId, CancellationToken ct = default) + { + var conversation = await LoadConversationAsync(conversationId, ct); + + return new ConversationDto( + conversation.Id, + conversation.ProjectId, + conversation.Title, + [.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)], + conversation.UpdatedAt); + } + + public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) + { + var conversation = await LoadConversationAsync(conversationId, ct); + db.Conversations.Remove(conversation); + await db.SaveChangesAsync(ct); + } + + /// + /// Sends a message to the agent and runs it to completion, executing any tools it + /// calls along the way. Returns the assistant's final turn. + /// + public async Task SendMessageAsync( + Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) + { + var conversation = request.ConversationId is { } id + ? await LoadConversationAsync(id, ct) + : await StartConversationAsync(projectId, request.Message, ct); + + // Persist the user's turn before running the loop. The tools save through the + // same DbContext, so leaving this pending would entangle it with their writes — + // and recording the question even if the model call fails is the behaviour we want. + await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); + + var systemPrompt = await BuildSystemPromptAsync(projectId, ct); + var transcript = BuildTranscript(conversation); + var toolCalls = new List(); + var text = new StringBuilder(); + + for (var iteration = 0; iteration < _options.MaxIterations; iteration++) + { + var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); + + foreach (var block in response.Content.OfType()) + { + if (!string.IsNullOrWhiteSpace(block.Text)) + { + text.AppendLine(block.Text.Trim()); + } + } + + var requestedTools = response.Content.OfType().ToList(); + if (requestedTools.Count == 0) + { + break; + } + + // Echo the assistant's turn back verbatim, then answer every tool_use block in a + // single user turn — splitting the results would train the model out of + // requesting tools in parallel. + transcript.Add(AgentChatMessage.Assistant(response.Content)); + + var results = new List(); + foreach (var call in requestedTools) + { + var (result, isError) = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct); + + logger.LogInformation( + "Agent tool {Tool} on project {ProjectId} {Outcome}", + call.Name, projectId, isError ? "failed" : "succeeded"); + + toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), result)); + results.Add(new AgentToolResultBlock(call.Id, result, isError)); + } + + transcript.Add(AgentChatMessage.User([.. results])); + + if (iteration == _options.MaxIterations - 1) + { + logger.LogWarning( + "Agent hit the {Max}-iteration ceiling on project {ProjectId}", + _options.MaxIterations, projectId); + + text.AppendLine( + "_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._"); + } + } + + var reply = await AppendMessageAsync( + conversation, + AgentRole.Assistant, + text.ToString().TrimEnd(), + toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null, + ct); + + return new AgentTurnDto(conversation.Id, ToDto(reply)); + } + + /// + /// Appends a turn and commits it. Messages are added to the set directly rather than + /// through the parent's collection so their insert never depends on EF discovering + /// the graph change at an inconvenient moment. + /// + private async Task AppendMessageAsync( + AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct) + { + var message = new AgentMessage + { + ConversationId = conversation.Id, + Role = role, + Sequence = conversation.Messages.Count == 0 ? 0 : conversation.Messages.Max(m => m.Sequence) + 1, + Content = content, + ToolCallsJson = toolCallsJson + }; + + db.AgentMessages.Add(message); + conversation.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + + // EF's relationship fixup normally puts the message into the parent's collection + // once both are tracked. Guard rather than assume, since the sequence number of + // the next turn is derived from it. + if (!conversation.Messages.Contains(message)) + { + conversation.Messages.Add(message); + } + + return message; + } + + private async Task StartConversationAsync( + Guid projectId, string firstMessage, CancellationToken ct) + { + if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) + { + throw new NotFoundException(nameof(Project), projectId); + } + + var conversation = new AgentConversation + { + ProjectId = projectId, + Title = Summarise(firstMessage) + }; + + db.Conversations.Add(conversation); + return conversation; + } + + private async Task LoadConversationAsync(Guid conversationId, CancellationToken ct) => + await db.Conversations + .Include(c => c.Messages) + .FirstOrDefaultAsync(c => c.Id == conversationId, ct) + ?? throw new NotFoundException(nameof(AgentConversation), conversationId); + + /// + /// Replays the stored conversation as plain text turns. Tool calls are not replayed — + /// the agent re-reads current state through its tools, which is more reliable than + /// trusting a transcript of edits that may since have been changed in the UI. + /// + private static List BuildTranscript(AgentConversation conversation) => + [ + .. conversation.Messages + .Where(m => !string.IsNullOrWhiteSpace(m.Content)) + .OrderBy(m => m.Sequence) + .Select(m => new AgentChatMessage( + m.Role == AgentRole.User ? "user" : "assistant", + [new AgentTextBlock(m.Content)])) + ]; + + private async Task BuildSystemPromptAsync(Guid projectId, CancellationToken ct) + { + var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct) + ?? throw new NotFoundException(nameof(Project), projectId); + + var brief = new StringBuilder(); + brief.AppendLine($"Title: {project.Title}"); + if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}"); + if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}"); + if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words"); + + return $""" + You are a developmental editor and writing partner embedded in the software the + writer is using to plan their novel. You have tools that read and write the + project's real data: the brief, character dossiers, the outline tree, chapters + and scenes. + + The project you are working on: + {brief} + Working principles: + + - Read before you write. Call get_project_brief, get_outline, or list_characters + to ground yourself rather than assuming what is already there. + - The book is the writer's. Ask about the choices that define the story — what a + character wants, what the ending costs them — instead of deciding for them. + - Do not invent biographical detail to fill an empty field. An unanswered + question in a dossier is more useful than a plausible-sounding fabrication. + - When you do have enough to act, act. Make the edit and say what you changed in + a sentence; do not narrate every tool call or ask permission for routine work. + - Prefer structural help — where a beat lands, whether a want and a need are + genuinely in tension, what the outline is missing — over line-level polish, + unless the writer asks for prose. + - When drafting prose into a scene, match the voice already established in the + project. Write the scene, then stop; do not append notes about your choices. + - Destructive operations (deleting outline nodes) need the writer's explicit + go-ahead first. + + Keep replies short. Lead with the outcome, then the reasoning if it earns its place. + """; + } + + private static AgentMessageDto ToDto(AgentMessage message) => new( + message.Id, + message.Role, + message.Content, + message.ToolCallsJson is null + ? [] + : JsonSerializer.Deserialize>(message.ToolCallsJson, JsonOptions) ?? [], + message.CreatedAt); + + /// Derives a conversation title from its opening message. + private static string Summarise(string message) + { + var trimmed = message.Trim().ReplaceLineEndings(" "); + return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "..."); + } +} diff --git a/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs b/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs new file mode 100644 index 0000000..6b0b182 --- /dev/null +++ b/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs @@ -0,0 +1,360 @@ +using System.Text.Json; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Application.Services; +using NovelSoftware.Domain; + +namespace NovelSoftware.Application.Agent; + +/// A tool the agent can call, bound to a handler that runs against the project's data. +public sealed record AgentTool( + string Name, + string Description, + JsonElement InputSchema, + Func> Handler); + +/// +/// The tools the writing agent can reach for. Everything here goes through the same +/// application services the REST API uses, so an edit made by the agent is +/// indistinguishable from one made in the UI. +/// +public class NovelAgentToolset( + ProjectService projects, + CharacterService characters, + OutlineService outlines, + ChapterService chapters, + SceneService scenes) +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + + private Dictionary? _byName; + + public IReadOnlyList Tools => [.. ByName.Values]; + + public IReadOnlyList Definitions => + [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; + + /// + /// Runs a tool and serialises its result. Failures come back as text rather than + /// exceptions so the model can read the message and correct itself. + /// + public async Task<(string Result, bool IsError)> ExecuteAsync( + string name, Guid projectId, JsonElement input, CancellationToken ct = default) + { + if (!ByName.TryGetValue(name, out var tool)) + { + return ($"No such tool: '{name}'.", true); + } + + try + { + var result = await tool.Handler(projectId, input, ct); + return (JsonSerializer.Serialize(result, SerializerOptions), false); + } + catch (NotFoundException ex) + { + return (ex.Message, true); + } + catch (ArgumentException ex) + { + return (ex.Message, true); + } + catch (InvalidOperationException ex) + { + return (ex.Message, true); + } + } + + private Dictionary ByName => _byName ??= Build().ToDictionary(t => t.Name); + + private IEnumerable Build() + { + yield return new AgentTool( + "get_project_brief", + "Read the project's title, logline, synopsis, genre, notes and word-count target. " + + "Call this first in a conversation to ground yourself in what the book is.", + new JsonSchemaBuilder().Build(), + async (projectId, _, ct) => await projects.GetAsync(projectId, ct)); + + yield return new AgentTool( + "update_project_brief", + "Revise the project's top-level fields. Only the fields you supply change; " + + "pass an empty string to clear a field.", + new JsonSchemaBuilder() + .Str("title", "New title.") + .Str("author", "Author name.") + .Str("genre", "Genre or category.") + .Str("logline", "One-sentence pitch.") + .Str("synopsis", "Paragraph-length summary of the whole book.") + .Str("notes", "Free-form notes on theme, tone, comparable titles.") + .Int("target_word_count", "Target manuscript length in words.") + .Build(), + async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest( + JsonInput.String(input, "title"), + JsonInput.String(input, "author"), + JsonInput.String(input, "genre"), + JsonInput.String(input, "logline"), + JsonInput.String(input, "synopsis"), + JsonInput.String(input, "notes"), + JsonInput.Int(input, "target_word_count")), ct)); + + yield return new AgentTool( + "list_characters", + "List every character in the project with their full dossiers.", + new JsonSchemaBuilder().Build(), + async (projectId, _, ct) => await characters.ListAsync(projectId, ct)); + + yield return new AgentTool( + "create_character", + "Add a character dossier. Name is the only requirement — leave fields blank when " + + "the writer has not decided them yet rather than inventing detail.", + CharacterSchema(includeName: true, nameRequired: true).Build(), + async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest( + JsonInput.RequiredString(input, "name"), + JsonInput.Enum(input, "role") ?? CharacterRole.Supporting, + JsonInput.String(input, "age"), + JsonInput.String(input, "pronouns"), + JsonInput.String(input, "occupation"), + JsonInput.String(input, "appearance"), + JsonInput.String(input, "personality"), + JsonInput.String(input, "backstory"), + JsonInput.String(input, "want"), + JsonInput.String(input, "need"), + JsonInput.String(input, "internal_conflict"), + JsonInput.String(input, "external_conflict"), + JsonInput.String(input, "arc_summary"), + JsonInput.String(input, "voice"), + JsonInput.String(input, "notes")), ct)); + + yield return new AgentTool( + "update_character", + "Revise an existing character dossier. Only the fields you supply change.", + CharacterSchema(includeName: true, nameRequired: false) + .Str("character_id", "Id of the character to update.", required: true) + .Build(), + async (_, input, ct) => await characters.UpdateAsync( + JsonInput.RequiredGuid(input, "character_id"), + new UpdateCharacterRequest( + JsonInput.String(input, "name"), + JsonInput.Enum(input, "role"), + JsonInput.String(input, "age"), + JsonInput.String(input, "pronouns"), + JsonInput.String(input, "occupation"), + JsonInput.String(input, "appearance"), + JsonInput.String(input, "personality"), + JsonInput.String(input, "backstory"), + JsonInput.String(input, "want"), + JsonInput.String(input, "need"), + JsonInput.String(input, "internal_conflict"), + JsonInput.String(input, "external_conflict"), + JsonInput.String(input, "arc_summary"), + JsonInput.String(input, "voice"), + JsonInput.String(input, "notes")), ct)); + + yield return new AgentTool( + "get_outline", + "Read the project's outline as a nested tree of parts, acts, sequences and beats.", + new JsonSchemaBuilder().Build(), + async (projectId, _, ct) => await outlines.GetTreeAsync(projectId, ct)); + + yield return new AgentTool( + "create_outline_node", + "Add a node to the outline. Pass parent_id to nest it; omit it for a top-level node.", + new JsonSchemaBuilder() + .Str("title", "Short label for the node.", required: true) + .Enum("node_type", "Structural level of the node.", System.Enum.GetNames()) + .Str("parent_id", "Id of the parent node, if nesting.") + .Str("summary", "What happens here, in a sentence or two.") + .Int("sort_order", "Position among siblings. Appended to the end when omitted.") + .Str("chapter_id", "Id of the chapter that realises this node, if one exists.") + .Build(), + async (projectId, input, ct) => await outlines.CreateAsync(projectId, new CreateOutlineNodeRequest( + JsonInput.RequiredString(input, "title"), + JsonInput.Enum(input, "node_type") ?? OutlineNodeType.Beat, + JsonInput.Guid(input, "parent_id"), + JsonInput.String(input, "summary"), + JsonInput.Int(input, "sort_order"), + JsonInput.Guid(input, "chapter_id")), ct)); + + yield return new AgentTool( + "update_outline_node", + "Revise an outline node's title, type, summary, position or linked chapter.", + new JsonSchemaBuilder() + .Str("node_id", "Id of the node to update.", required: true) + .Str("title", "New title.") + .Enum("node_type", "Structural level of the node.", System.Enum.GetNames()) + .Str("summary", "What happens here.") + .Int("sort_order", "Position among siblings.") + .Str("chapter_id", "Id of the chapter that realises this node.") + .Build(), + async (_, input, ct) => await outlines.UpdateAsync( + JsonInput.RequiredGuid(input, "node_id"), + new UpdateOutlineNodeRequest( + JsonInput.String(input, "title"), + JsonInput.Enum(input, "node_type"), + JsonInput.String(input, "summary"), + JsonInput.Int(input, "sort_order"), + JsonInput.Guid(input, "chapter_id")), ct)); + + yield return new AgentTool( + "delete_outline_node", + "Remove an outline node and everything nested beneath it. This cannot be undone, " + + "so confirm with the writer before calling it.", + new JsonSchemaBuilder() + .Str("node_id", "Id of the node to delete.", required: true) + .Build(), + async (_, input, ct) => + { + await outlines.DeleteAsync(JsonInput.RequiredGuid(input, "node_id"), ct); + return new { deleted = true }; + }); + + yield return new AgentTool( + "list_chapters", + "List the project's chapters in manuscript order with scene and word counts.", + new JsonSchemaBuilder().Build(), + async (projectId, _, ct) => await chapters.ListAsync(projectId, ct)); + + yield return new AgentTool( + "get_chapter", + "Read one chapter in full, including all of its scenes and any drafted prose.", + new JsonSchemaBuilder() + .Str("chapter_id", "Id of the chapter to read.", required: true) + .Build(), + async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)); + + yield return new AgentTool( + "create_chapter", + "Add a chapter. Its number is appended to the end of the manuscript unless you supply one.", + new JsonSchemaBuilder() + .Str("title", "Chapter title.", required: true) + .Int("number", "Position in the manuscript, 1-based.") + .Str("summary", "What the chapter covers.") + .Str("pov_character_id", "Id of the point-of-view character.") + .Str("setting", "Where and when the chapter takes place.") + .Str("notes", "Anything else worth recording.") + .Enum("status", "Drafting status.", System.Enum.GetNames()) + .Int("target_word_count", "Target length in words.") + .Build(), + async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest( + JsonInput.RequiredString(input, "title"), + JsonInput.Int(input, "number"), + JsonInput.String(input, "summary"), + JsonInput.Guid(input, "pov_character_id"), + JsonInput.String(input, "setting"), + JsonInput.String(input, "notes"), + JsonInput.Enum(input, "status") ?? DraftStatus.Planned, + JsonInput.Int(input, "target_word_count")), ct)); + + yield return new AgentTool( + "update_chapter", + "Revise a chapter's title, number, summary, POV, setting, notes or status.", + new JsonSchemaBuilder() + .Str("chapter_id", "Id of the chapter to update.", required: true) + .Str("title", "New title.") + .Int("number", "Position in the manuscript.") + .Str("summary", "What the chapter covers.") + .Str("pov_character_id", "Id of the point-of-view character.") + .Str("setting", "Where and when the chapter takes place.") + .Str("notes", "Anything else worth recording.") + .Enum("status", "Drafting status.", System.Enum.GetNames()) + .Int("target_word_count", "Target length in words.") + .Build(), + async (_, input, ct) => await chapters.UpdateAsync( + JsonInput.RequiredGuid(input, "chapter_id"), + new UpdateChapterRequest( + JsonInput.String(input, "title"), + JsonInput.Int(input, "number"), + JsonInput.String(input, "summary"), + JsonInput.Guid(input, "pov_character_id"), + JsonInput.String(input, "setting"), + JsonInput.String(input, "notes"), + JsonInput.Enum(input, "status"), + JsonInput.Int(input, "target_word_count")), ct)); + + yield return new AgentTool( + "create_scene", + "Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene " + + "draftable later, so fill those in when the writer has given you enough to work with.", + SceneSchema() + .Str("chapter_id", "Id of the chapter the scene belongs to.", required: true) + .Str("title", "Scene title.", required: true) + .Build(), + async (_, input, ct) => await scenes.CreateAsync( + JsonInput.RequiredGuid(input, "chapter_id"), + new CreateSceneRequest( + JsonInput.RequiredString(input, "title"), + JsonInput.Int(input, "sort_order"), + JsonInput.String(input, "summary"), + JsonInput.String(input, "goal"), + JsonInput.String(input, "conflict"), + JsonInput.String(input, "outcome"), + JsonInput.Guid(input, "pov_character_id"), + JsonInput.String(input, "location"), + JsonInput.String(input, "prose"), + JsonInput.Enum(input, "status") ?? DraftStatus.Planned), ct)); + + yield return new AgentTool( + "update_scene", + "Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; " + + "the word count is recomputed automatically.", + SceneSchema() + .Str("scene_id", "Id of the scene to update.", required: true) + .Str("title", "New title.") + .Build(), + async (_, input, ct) => await scenes.UpdateAsync( + JsonInput.RequiredGuid(input, "scene_id"), + new UpdateSceneRequest( + JsonInput.String(input, "title"), + JsonInput.Int(input, "sort_order"), + JsonInput.String(input, "summary"), + JsonInput.String(input, "goal"), + JsonInput.String(input, "conflict"), + JsonInput.String(input, "outcome"), + JsonInput.Guid(input, "pov_character_id"), + JsonInput.String(input, "location"), + JsonInput.String(input, "prose"), + JsonInput.Enum(input, "status")), ct)); + } + + private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired) + { + var schema = new JsonSchemaBuilder(); + + if (includeName) + { + schema.Str("name", "The character's name.", nameRequired); + } + + return schema + .Enum("role", "The part they play in the story.", System.Enum.GetNames()) + .Str("age", "Age, exact or approximate.") + .Str("pronouns", "The pronouns this character uses.") + .Str("occupation", "What they do.") + .Str("appearance", "How they look.") + .Str("personality", "Temperament, habits, how they treat people.") + .Str("backstory", "History that shapes who they are now.") + .Str("want", "What they consciously pursue.") + .Str("need", "What they actually need, usually at odds with what they want.") + .Str("internal_conflict", "The war inside them.") + .Str("external_conflict", "What in the world opposes them.") + .Str("arc_summary", "How they change over the course of the book.") + .Str("voice", "Speech patterns and register that make their dialogue theirs.") + .Str("notes", "Anything else worth recording."); + } + + private static JsonSchemaBuilder SceneSchema() => + new JsonSchemaBuilder() + .Int("sort_order", "Position within the chapter. Appended to the end when omitted.") + .Str("summary", "What happens in the scene.") + .Str("goal", "What the POV character is trying to achieve.") + .Str("conflict", "What stands in the way.") + .Str("outcome", "How it lands, and what it costs.") + .Str("pov_character_id", "Id of the point-of-view character.") + .Str("location", "Where the scene takes place.") + .Str("prose", "The drafted prose for this scene.") + .Enum("status", "Drafting status.", System.Enum.GetNames()); +} diff --git a/src/NovelSoftware.Application/AgentNotConfiguredException.cs b/src/NovelSoftware.Application/AgentNotConfiguredException.cs new file mode 100644 index 0000000..fb72f0d --- /dev/null +++ b/src/NovelSoftware.Application/AgentNotConfiguredException.cs @@ -0,0 +1,8 @@ +namespace NovelSoftware.Application; + +/// +/// Thrown when the agent is asked to run but has no model credentials. This is a +/// deployment problem rather than a bad request, so the API reports it as 503 — the rest +/// of the app works fine without a key. +/// +public class AgentNotConfiguredException(string message) : Exception(message); diff --git a/src/NovelSoftware.Application/Dtos/AgentDtos.cs b/src/NovelSoftware.Application/Dtos/AgentDtos.cs new file mode 100644 index 0000000..4e771b1 --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/AgentDtos.cs @@ -0,0 +1,31 @@ +using NovelSoftware.Domain; + +namespace NovelSoftware.Application.Dtos; + +public record ConversationSummaryDto( + Guid Id, + Guid ProjectId, + string Title, + int MessageCount, + DateTimeOffset UpdatedAt); + +public record ConversationDto( + Guid Id, + Guid ProjectId, + string Title, + IReadOnlyList Messages, + DateTimeOffset UpdatedAt); + +public record AgentMessageDto( + Guid Id, + AgentRole Role, + string Content, + IReadOnlyList ToolCalls, + DateTimeOffset CreatedAt); + +/// A record of one tool the agent invoked, surfaced so the writer can audit changes. +public record ToolCallDto(string Name, string Input, string Result); + +public record SendAgentMessageRequest(string Message, Guid? ConversationId = null); + +public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message); diff --git a/src/NovelSoftware.Application/Dtos/ChapterDtos.cs b/src/NovelSoftware.Application/Dtos/ChapterDtos.cs new file mode 100644 index 0000000..566ba9c --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/ChapterDtos.cs @@ -0,0 +1,68 @@ +using NovelSoftware.Domain; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Dtos; + +public record ChapterSummaryDto( + Guid Id, + Guid ProjectId, + int Number, + string Title, + string? Summary, + Guid? PovCharacterId, + string? PovCharacterName, + string? Setting, + DraftStatus Status, + int? TargetWordCount, + int SceneCount, + int WordCount); + +public record ChapterDto( + Guid Id, + Guid ProjectId, + int Number, + string Title, + string? Summary, + Guid? PovCharacterId, + string? PovCharacterName, + string? Setting, + string? Notes, + DraftStatus Status, + int? TargetWordCount, + IReadOnlyList Scenes, + DateTimeOffset UpdatedAt); + +public record CreateChapterRequest( + string Title, + int? Number = null, + string? Summary = null, + Guid? PovCharacterId = null, + string? Setting = null, + string? Notes = null, + DraftStatus Status = DraftStatus.Planned, + int? TargetWordCount = null); + +public record UpdateChapterRequest( + string? Title = null, + int? Number = null, + string? Summary = null, + Guid? PovCharacterId = null, + string? Setting = null, + string? Notes = null, + DraftStatus? Status = null, + int? TargetWordCount = null); + +public static class ChapterMapping +{ + public static ChapterDto ToDto(this Chapter c) => new( + c.Id, c.ProjectId, c.Number, c.Title, c.Summary, + c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, + c.Status, c.TargetWordCount, + [.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())], + c.UpdatedAt); + + public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new( + c.Id, c.ProjectId, c.Number, c.Title, c.Summary, + c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount, + c.Scenes.Count, c.Scenes.Sum(s => s.WordCount)); +} diff --git a/src/NovelSoftware.Application/Dtos/CharacterDtos.cs b/src/NovelSoftware.Application/Dtos/CharacterDtos.cs new file mode 100644 index 0000000..001812a --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/CharacterDtos.cs @@ -0,0 +1,86 @@ +using NovelSoftware.Domain; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Dtos; + +public record CharacterDto( + Guid Id, + Guid ProjectId, + string Name, + CharacterRole Role, + string? Age, + string? Pronouns, + string? Occupation, + string? Appearance, + string? Personality, + string? Backstory, + string? Want, + string? Need, + string? InternalConflict, + string? ExternalConflict, + string? ArcSummary, + string? Voice, + string? Notes, + IReadOnlyList Relationships, + DateTimeOffset UpdatedAt); + +public record RelationshipDto( + Guid Id, + Guid RelatedCharacterId, + string RelatedCharacterName, + string RelationshipType, + string? Description); + +public record CreateCharacterRequest( + string Name, + CharacterRole Role = CharacterRole.Supporting, + string? Age = null, + string? Pronouns = null, + string? Occupation = null, + string? Appearance = null, + string? Personality = null, + string? Backstory = null, + string? Want = null, + string? Need = null, + string? InternalConflict = null, + string? ExternalConflict = null, + string? ArcSummary = null, + string? Voice = null, + string? Notes = null); + +public record UpdateCharacterRequest( + string? Name = null, + CharacterRole? Role = null, + string? Age = null, + string? Pronouns = null, + string? Occupation = null, + string? Appearance = null, + string? Personality = null, + string? Backstory = null, + string? Want = null, + string? Need = null, + string? InternalConflict = null, + string? ExternalConflict = null, + string? ArcSummary = null, + string? Voice = null, + string? Notes = null); + +public record CreateRelationshipRequest( + Guid RelatedCharacterId, + string RelationshipType, + string? Description = null); + +public static class CharacterMapping +{ + public static CharacterDto ToDto(this Character c) => new( + c.Id, c.ProjectId, c.Name, c.Role, c.Age, c.Pronouns, c.Occupation, + c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, + c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes, + [.. c.Relationships.Select(r => new RelationshipDto( + r.Id, + r.RelatedCharacterId, + r.RelatedCharacter?.Name ?? "(unknown)", + r.RelationshipType, + r.Description))], + c.UpdatedAt); +} diff --git a/src/NovelSoftware.Application/Dtos/OutlineDtos.cs b/src/NovelSoftware.Application/Dtos/OutlineDtos.cs new file mode 100644 index 0000000..7b283ab --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/OutlineDtos.cs @@ -0,0 +1,33 @@ +using NovelSoftware.Domain; + +namespace NovelSoftware.Application.Dtos; + +/// An outline node with its subtree inlined — the shape the outline view renders. +public record OutlineNodeDto( + Guid Id, + Guid ProjectId, + Guid? ParentId, + OutlineNodeType NodeType, + string Title, + string? Summary, + int SortOrder, + Guid? ChapterId, + IReadOnlyList Children); + +public record CreateOutlineNodeRequest( + string Title, + OutlineNodeType NodeType = OutlineNodeType.Beat, + Guid? ParentId = null, + string? Summary = null, + int? SortOrder = null, + Guid? ChapterId = null); + +public record UpdateOutlineNodeRequest( + string? Title = null, + OutlineNodeType? NodeType = null, + string? Summary = null, + int? SortOrder = null, + Guid? ChapterId = null); + +/// Moves a node to a new parent and/or position. A null means root level. +public record MoveOutlineNodeRequest(Guid? ParentId, int SortOrder); diff --git a/src/NovelSoftware.Application/Dtos/ProjectDtos.cs b/src/NovelSoftware.Application/Dtos/ProjectDtos.cs new file mode 100644 index 0000000..ebe4d59 --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/ProjectDtos.cs @@ -0,0 +1,56 @@ +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Dtos; + +public record ProjectSummaryDto( + Guid Id, + string Title, + string? Author, + string? Genre, + string? Logline, + int? TargetWordCount, + int CharacterCount, + int ChapterCount, + int WordCount, + DateTimeOffset UpdatedAt); + +public record ProjectDto( + Guid Id, + string Title, + string? Author, + string? Genre, + string? Logline, + string? Synopsis, + string? Notes, + int? TargetWordCount, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public record CreateProjectRequest( + string Title, + string? Author = null, + string? Genre = null, + string? Logline = null, + string? Synopsis = null, + string? Notes = null, + int? TargetWordCount = null); + +/// +/// Patch-style update: every field is optional and null means "leave alone". +/// Clearing a field is done by sending an empty string. +/// +public record UpdateProjectRequest( + string? Title = null, + string? Author = null, + string? Genre = null, + string? Logline = null, + string? Synopsis = null, + string? Notes = null, + int? TargetWordCount = null); + +public static class ProjectMapping +{ + public static ProjectDto ToDto(this Project p) => new( + p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes, + p.TargetWordCount, p.CreatedAt, p.UpdatedAt); +} diff --git a/src/NovelSoftware.Application/Dtos/SceneDtos.cs b/src/NovelSoftware.Application/Dtos/SceneDtos.cs new file mode 100644 index 0000000..6aa873c --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/SceneDtos.cs @@ -0,0 +1,63 @@ +using NovelSoftware.Domain; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Dtos; + +public record SceneDto( + Guid Id, + Guid ChapterId, + int SortOrder, + string Title, + string? Summary, + string? Goal, + string? Conflict, + string? Outcome, + Guid? PovCharacterId, + string? PovCharacterName, + string? Location, + string? Prose, + int WordCount, + DraftStatus Status, + DateTimeOffset UpdatedAt); + +public record CreateSceneRequest( + string Title, + int? SortOrder = null, + string? Summary = null, + string? Goal = null, + string? Conflict = null, + string? Outcome = null, + Guid? PovCharacterId = null, + string? Location = null, + string? Prose = null, + DraftStatus Status = DraftStatus.Planned); + +public record UpdateSceneRequest( + string? Title = null, + int? SortOrder = null, + string? Summary = null, + string? Goal = null, + string? Conflict = null, + string? Outcome = null, + Guid? PovCharacterId = null, + string? Location = null, + string? Prose = null, + DraftStatus? Status = null); + +public static class SceneMapping +{ + public static SceneDto ToDto(this Scene s) => new( + s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary, + s.Goal, s.Conflict, s.Outcome, + s.PovCharacterId, s.PovCharacter?.Name, s.Location, + s.Prose, s.WordCount, s.Status, s.UpdatedAt); + + /// + /// Whitespace-delimited word count. Good enough for progress tracking, and it costs + /// nothing to recompute on every save. + /// + public static int CountWords(string? prose) => + string.IsNullOrWhiteSpace(prose) + ? 0 + : prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; +} diff --git a/src/NovelSoftware.Application/INovelDbContext.cs b/src/NovelSoftware.Application/INovelDbContext.cs new file mode 100644 index 0000000..b2266d0 --- /dev/null +++ b/src/NovelSoftware.Application/INovelDbContext.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application; + +/// +/// The persistence surface the application services depend on. Infrastructure supplies +/// the EF Core implementation; tests can point it at an in-memory SQLite connection. +/// +public interface INovelDbContext +{ + DbSet Projects { get; } + DbSet Characters { get; } + DbSet CharacterRelationships { get; } + DbSet OutlineNodes { get; } + DbSet Chapters { get; } + DbSet Scenes { get; } + DbSet Conversations { get; } + DbSet AgentMessages { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/NovelSoftware.Application/NotFoundException.cs b/src/NovelSoftware.Application/NotFoundException.cs new file mode 100644 index 0000000..3130b07 --- /dev/null +++ b/src/NovelSoftware.Application/NotFoundException.cs @@ -0,0 +1,12 @@ +namespace NovelSoftware.Application; + +/// +/// Thrown when a service is asked for an entity that does not exist. The API translates +/// this into a 404 so services never have to know about HTTP. +/// +public class NotFoundException(string entity, Guid id) + : Exception($"{entity} '{id}' was not found.") +{ + public string Entity { get; } = entity; + public Guid Id { get; } = id; +} diff --git a/src/NovelSoftware.Application/NovelSoftware.Application.csproj b/src/NovelSoftware.Application/NovelSoftware.Application.csproj new file mode 100644 index 0000000..f614db7 --- /dev/null +++ b/src/NovelSoftware.Application/NovelSoftware.Application.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/NovelSoftware.Application/Services/ChapterService.cs b/src/NovelSoftware.Application/Services/ChapterService.cs new file mode 100644 index 0000000..90b0191 --- /dev/null +++ b/src/NovelSoftware.Application/Services/ChapterService.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Services; + +public class ChapterService(INovelDbContext db) +{ + public async Task> ListAsync(Guid projectId, CancellationToken ct = default) + { + var chapters = await db.Chapters + .Include(c => c.PovCharacter) + .Include(c => c.Scenes) + .Where(c => c.ProjectId == projectId) + .OrderBy(c => c.Number) + .ToListAsync(ct); + + return [.. chapters.Select(c => c.ToSummaryDto())]; + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) => + (await FindAsync(id, ct)).ToDto(); + + public async Task CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) + { + if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) + { + throw new NotFoundException(nameof(Project), projectId); + } + + var chapter = new Chapter + { + ProjectId = projectId, + Title = request.Title, + Number = request.Number ?? await NextChapterNumberAsync(projectId, ct), + Summary = request.Summary, + PovCharacterId = request.PovCharacterId, + Setting = request.Setting, + Notes = request.Notes, + Status = request.Status, + TargetWordCount = request.TargetWordCount + }; + + db.Chapters.Add(chapter); + await db.SaveChangesAsync(ct); + return (await FindAsync(chapter.Id, ct)).ToDto(); + } + + public async Task UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default) + { + var chapter = await FindAsync(id, ct); + + chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; + chapter.Number = request.Number ?? chapter.Number; + chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); + chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId; + chapter.Setting = Patch.Apply(chapter.Setting, request.Setting); + chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); + chapter.Status = request.Status ?? chapter.Status; + chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; + chapter.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (await FindAsync(id, ct)).ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var chapter = await FindAsync(id, ct); + db.Chapters.Remove(chapter); + await db.SaveChangesAsync(ct); + } + + private async Task NextChapterNumberAsync(Guid projectId, CancellationToken ct) + { + var max = await db.Chapters + .Where(c => c.ProjectId == projectId) + .MaxAsync(c => (int?)c.Number, ct); + + return (max ?? 0) + 1; + } + + private async Task FindAsync(Guid id, CancellationToken ct) => + await db.Chapters + .Include(c => c.PovCharacter) + .Include(c => c.Scenes) + .ThenInclude(s => s.PovCharacter) + .FirstOrDefaultAsync(c => c.Id == id, ct) + ?? throw new NotFoundException(nameof(Chapter), id); +} diff --git a/src/NovelSoftware.Application/Services/CharacterService.cs b/src/NovelSoftware.Application/Services/CharacterService.cs new file mode 100644 index 0000000..5b68c57 --- /dev/null +++ b/src/NovelSoftware.Application/Services/CharacterService.cs @@ -0,0 +1,136 @@ +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Services; + +public class CharacterService(INovelDbContext db) +{ + public async Task> ListAsync(Guid projectId, CancellationToken ct = default) + { + var characters = await Query() + .Where(c => c.ProjectId == projectId) + .OrderBy(c => c.Role) + .ThenBy(c => c.Name) + .ToListAsync(ct); + + return [.. characters.Select(c => c.ToDto())]; + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) => + (await FindAsync(id, ct)).ToDto(); + + public async Task CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) + { + await EnsureProjectExists(projectId, ct); + + var character = new Character + { + ProjectId = projectId, + Name = request.Name, + Role = request.Role, + Age = request.Age, + Pronouns = request.Pronouns, + Occupation = request.Occupation, + Appearance = request.Appearance, + Personality = request.Personality, + Backstory = request.Backstory, + Want = request.Want, + Need = request.Need, + InternalConflict = request.InternalConflict, + ExternalConflict = request.ExternalConflict, + ArcSummary = request.ArcSummary, + Voice = request.Voice, + Notes = request.Notes + }; + + db.Characters.Add(character); + await db.SaveChangesAsync(ct); + return character.ToDto(); + } + + public async Task UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default) + { + var character = await FindAsync(id, ct); + + character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name; + character.Role = request.Role ?? character.Role; + character.Age = Patch.Apply(character.Age, request.Age); + character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns); + character.Occupation = Patch.Apply(character.Occupation, request.Occupation); + character.Appearance = Patch.Apply(character.Appearance, request.Appearance); + character.Personality = Patch.Apply(character.Personality, request.Personality); + character.Backstory = Patch.Apply(character.Backstory, request.Backstory); + character.Want = Patch.Apply(character.Want, request.Want); + character.Need = Patch.Apply(character.Need, request.Need); + character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict); + character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict); + character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary); + character.Voice = Patch.Apply(character.Voice, request.Voice); + character.Notes = Patch.Apply(character.Notes, request.Notes); + character.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return character.ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var character = await FindAsync(id, ct); + db.Characters.Remove(character); + await db.SaveChangesAsync(ct); + } + + public async Task AddRelationshipAsync( + Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default) + { + var character = await FindAsync(characterId, ct); + + var related = await db.Characters + .FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct) + ?? throw new NotFoundException(nameof(Character), request.RelatedCharacterId); + + if (related.ProjectId != character.ProjectId) + { + throw new InvalidOperationException("Characters must belong to the same project to be related."); + } + + db.CharacterRelationships.Add(new CharacterRelationship + { + CharacterId = characterId, + RelatedCharacterId = request.RelatedCharacterId, + RelationshipType = request.RelationshipType, + Description = request.Description + }); + + await db.SaveChangesAsync(ct); + return (await FindAsync(characterId, ct)).ToDto(); + } + + public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default) + { + var relationship = await db.CharacterRelationships + .FirstOrDefaultAsync(r => r.Id == relationshipId, ct) + ?? throw new NotFoundException(nameof(CharacterRelationship), relationshipId); + + db.CharacterRelationships.Remove(relationship); + await db.SaveChangesAsync(ct); + } + + private IQueryable Query() => + db.Characters + .Include(c => c.Relationships) + .ThenInclude(r => r.RelatedCharacter); + + private async Task FindAsync(Guid id, CancellationToken ct) => + await Query().FirstOrDefaultAsync(c => c.Id == id, ct) + ?? throw new NotFoundException(nameof(Character), id); + + private async Task EnsureProjectExists(Guid projectId, CancellationToken ct) + { + if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) + { + throw new NotFoundException(nameof(Project), projectId); + } + } +} diff --git a/src/NovelSoftware.Application/Services/OutlineService.cs b/src/NovelSoftware.Application/Services/OutlineService.cs new file mode 100644 index 0000000..3f3a5b6 --- /dev/null +++ b/src/NovelSoftware.Application/Services/OutlineService.cs @@ -0,0 +1,166 @@ +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Services; + +public class OutlineService(INovelDbContext db) +{ + /// Returns the project's outline as a tree of root nodes with children inlined. + public async Task> GetTreeAsync(Guid projectId, CancellationToken ct = default) + { + var nodes = await db.OutlineNodes + .Where(n => n.ProjectId == projectId) + .ToListAsync(ct); + + return BuildTree(nodes, parentId: null); + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) + { + var node = await FindAsync(id, ct); + var siblings = await db.OutlineNodes.Where(n => n.ProjectId == node.ProjectId).ToListAsync(ct); + return BuildNode(node, siblings); + } + + public async Task CreateAsync( + Guid projectId, CreateOutlineNodeRequest request, CancellationToken ct = default) + { + if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) + { + throw new NotFoundException(nameof(Project), projectId); + } + + if (request.ParentId is { } parentId && !await db.OutlineNodes.AnyAsync(n => n.Id == parentId, ct)) + { + throw new NotFoundException(nameof(OutlineNode), parentId); + } + + var node = new OutlineNode + { + ProjectId = projectId, + ParentId = request.ParentId, + NodeType = request.NodeType, + Title = request.Title, + Summary = request.Summary, + ChapterId = request.ChapterId, + SortOrder = request.SortOrder ?? await NextSortOrderAsync(projectId, request.ParentId, ct) + }; + + db.OutlineNodes.Add(node); + await db.SaveChangesAsync(ct); + return BuildNode(node, []); + } + + public async Task UpdateAsync( + Guid id, UpdateOutlineNodeRequest request, CancellationToken ct = default) + { + var node = await FindAsync(id, ct); + + node.Title = Patch.Apply(node.Title, request.Title) ?? node.Title; + node.NodeType = request.NodeType ?? node.NodeType; + node.Summary = Patch.Apply(node.Summary, request.Summary); + node.SortOrder = request.SortOrder ?? node.SortOrder; + node.ChapterId = request.ChapterId ?? node.ChapterId; + node.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return await GetAsync(id, ct); + } + + /// + /// Reparents a node. Refuses to move a node under one of its own descendants, which + /// would detach the subtree from the tree entirely. + /// + public async Task MoveAsync(Guid id, MoveOutlineNodeRequest request, CancellationToken ct = default) + { + var node = await FindAsync(id, ct); + + if (request.ParentId == id) + { + throw new InvalidOperationException("An outline node cannot be its own parent."); + } + + if (request.ParentId is { } newParentId) + { + var allNodes = await db.OutlineNodes + .Where(n => n.ProjectId == node.ProjectId) + .ToListAsync(ct); + + if (!allNodes.Any(n => n.Id == newParentId)) + { + throw new NotFoundException(nameof(OutlineNode), newParentId); + } + + if (DescendantIds(allNodes, id).Contains(newParentId)) + { + throw new InvalidOperationException("An outline node cannot be moved beneath its own descendant."); + } + } + + node.ParentId = request.ParentId; + node.SortOrder = request.SortOrder; + node.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return await GetAsync(id, ct); + } + + /// Deletes a node and its entire subtree. + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var node = await FindAsync(id, ct); + + var allNodes = await db.OutlineNodes + .Where(n => n.ProjectId == node.ProjectId) + .ToListAsync(ct); + + var doomed = DescendantIds(allNodes, id).Append(id).ToHashSet(); + db.OutlineNodes.RemoveRange(allNodes.Where(n => doomed.Contains(n.Id))); + + await db.SaveChangesAsync(ct); + } + + private async Task NextSortOrderAsync(Guid projectId, Guid? parentId, CancellationToken ct) + { + var max = await db.OutlineNodes + .Where(n => n.ProjectId == projectId && n.ParentId == parentId) + .MaxAsync(n => (int?)n.SortOrder, ct); + + return (max ?? 0) + 1; + } + + private async Task FindAsync(Guid id, CancellationToken ct) => + await db.OutlineNodes.FirstOrDefaultAsync(n => n.Id == id, ct) + ?? throw new NotFoundException(nameof(OutlineNode), id); + + private static IReadOnlyList BuildTree(List all, Guid? parentId) => + [ + .. all + .Where(n => n.ParentId == parentId) + .OrderBy(n => n.SortOrder) + .ThenBy(n => n.Title) + .Select(n => new OutlineNodeDto( + n.Id, n.ProjectId, n.ParentId, n.NodeType, n.Title, n.Summary, + n.SortOrder, n.ChapterId, BuildTree(all, n.Id))) + ]; + + private static OutlineNodeDto BuildNode(OutlineNode node, List all) => new( + node.Id, node.ProjectId, node.ParentId, node.NodeType, node.Title, node.Summary, + node.SortOrder, node.ChapterId, BuildTree(all, node.Id)); + + private static IEnumerable DescendantIds(List all, Guid rootId) + { + var frontier = new Queue([rootId]); + + while (frontier.Count > 0) + { + var current = frontier.Dequeue(); + foreach (var child in all.Where(n => n.ParentId == current)) + { + yield return child.Id; + frontier.Enqueue(child.Id); + } + } + } +} diff --git a/src/NovelSoftware.Application/Services/ProjectService.cs b/src/NovelSoftware.Application/Services/ProjectService.cs new file mode 100644 index 0000000..213dc71 --- /dev/null +++ b/src/NovelSoftware.Application/Services/ProjectService.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Services; + +public class ProjectService(INovelDbContext db) +{ + public async Task> ListAsync(CancellationToken ct = default) => + await db.Projects + .OrderByDescending(p => p.UpdatedAt) + .Select(p => new ProjectSummaryDto( + p.Id, + p.Title, + p.Author, + p.Genre, + p.Logline, + p.TargetWordCount, + p.Characters.Count, + p.Chapters.Count, + p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0, + p.UpdatedAt)) + .ToListAsync(ct); + + public async Task GetAsync(Guid id, CancellationToken ct = default) => + (await FindAsync(id, ct)).ToDto(); + + public async Task CreateAsync(CreateProjectRequest request, CancellationToken ct = default) + { + var project = new Project + { + Title = request.Title, + Author = request.Author, + Genre = request.Genre, + Logline = request.Logline, + Synopsis = request.Synopsis, + Notes = request.Notes, + TargetWordCount = request.TargetWordCount + }; + + db.Projects.Add(project); + await db.SaveChangesAsync(ct); + return project.ToDto(); + } + + public async Task UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default) + { + var project = await FindAsync(id, ct); + + project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title; + project.Author = Patch.Apply(project.Author, request.Author); + project.Genre = Patch.Apply(project.Genre, request.Genre); + project.Logline = Patch.Apply(project.Logline, request.Logline); + project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis); + project.Notes = Patch.Apply(project.Notes, request.Notes); + project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount; + project.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return project.ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var project = await FindAsync(id, ct); + db.Projects.Remove(project); + await db.SaveChangesAsync(ct); + } + + private async Task FindAsync(Guid id, CancellationToken ct) => + await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct) + ?? throw new NotFoundException(nameof(Project), id); +} + +/// +/// Patch semantics shared by every update endpoint: a null value leaves the field +/// untouched, an empty string clears it. +/// +internal static class Patch +{ + public static string? Apply(string? current, string? incoming) => incoming switch + { + null => current, + "" => null, + _ => incoming + }; +} diff --git a/src/NovelSoftware.Application/Services/SceneService.cs b/src/NovelSoftware.Application/Services/SceneService.cs new file mode 100644 index 0000000..3b49ba8 --- /dev/null +++ b/src/NovelSoftware.Application/Services/SceneService.cs @@ -0,0 +1,97 @@ +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Services; + +public class SceneService(INovelDbContext db) +{ + public async Task> ListAsync(Guid chapterId, CancellationToken ct = default) + { + var scenes = await Query() + .Where(s => s.ChapterId == chapterId) + .OrderBy(s => s.SortOrder) + .ToListAsync(ct); + + return [.. scenes.Select(s => s.ToDto())]; + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) => + (await FindAsync(id, ct)).ToDto(); + + public async Task CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default) + { + if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct)) + { + throw new NotFoundException(nameof(Chapter), chapterId); + } + + var scene = new Scene + { + ChapterId = chapterId, + Title = request.Title, + SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct), + Summary = request.Summary, + Goal = request.Goal, + Conflict = request.Conflict, + Outcome = request.Outcome, + PovCharacterId = request.PovCharacterId, + Location = request.Location, + Prose = request.Prose, + WordCount = SceneMapping.CountWords(request.Prose), + Status = request.Status + }; + + db.Scenes.Add(scene); + await db.SaveChangesAsync(ct); + return (await FindAsync(scene.Id, ct)).ToDto(); + } + + public async Task UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default) + { + var scene = await FindAsync(id, ct); + + scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title; + scene.SortOrder = request.SortOrder ?? scene.SortOrder; + scene.Summary = Patch.Apply(scene.Summary, request.Summary); + scene.Goal = Patch.Apply(scene.Goal, request.Goal); + scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict); + scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome); + scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId; + scene.Location = Patch.Apply(scene.Location, request.Location); + scene.Status = request.Status ?? scene.Status; + + if (request.Prose is not null) + { + scene.Prose = Patch.Apply(scene.Prose, request.Prose); + scene.WordCount = SceneMapping.CountWords(scene.Prose); + } + + scene.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (await FindAsync(id, ct)).ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var scene = await FindAsync(id, ct); + db.Scenes.Remove(scene); + await db.SaveChangesAsync(ct); + } + + private async Task NextSortOrderAsync(Guid chapterId, CancellationToken ct) + { + var max = await db.Scenes + .Where(s => s.ChapterId == chapterId) + .MaxAsync(s => (int?)s.SortOrder, ct); + + return (max ?? 0) + 1; + } + + private IQueryable Query() => db.Scenes.Include(s => s.PovCharacter); + + private async Task FindAsync(Guid id, CancellationToken ct) => + await Query().FirstOrDefaultAsync(s => s.Id == id, ct) + ?? throw new NotFoundException(nameof(Scene), id); +} diff --git a/src/NovelSoftware.Domain/Entities/AgentConversation.cs b/src/NovelSoftware.Domain/Entities/AgentConversation.cs new file mode 100644 index 0000000..d4c7070 --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/AgentConversation.cs @@ -0,0 +1,47 @@ +namespace NovelSoftware.Domain.Entities; + +/// A chat thread between the writer and the embedded agent, scoped to one project. +public class AgentConversation +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ProjectId { get; set; } + public Project? Project { get; set; } + + public string Title { get; set; } = "New conversation"; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public List Messages { get; set; } = []; +} + +/// +/// One turn in an agent conversation. Assistant turns may carry a record of the tools +/// the agent called, so the UI can show what it changed and the next request can replay +/// the turn back to the model. +/// +public class AgentMessage +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ConversationId { get; set; } + public AgentConversation? Conversation { get; set; } + + public AgentRole Role { get; set; } + + /// + /// Position in the conversation, 0-based. Timestamps are not enough to order a + /// transcript: a fast turn can produce two messages inside the same tick. + /// + public int Sequence { get; set; } + + /// The visible text of the turn. + public string Content { get; set; } = string.Empty; + + /// + /// JSON array of { name, input, result } objects describing tool calls made + /// during this turn. Null on user turns and on assistant turns that used no tools. + /// + public string? ToolCallsJson { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/NovelSoftware.Domain/Entities/Chapter.cs b/src/NovelSoftware.Domain/Entities/Chapter.cs new file mode 100644 index 0000000..e31108c --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/Chapter.cs @@ -0,0 +1,30 @@ +namespace NovelSoftware.Domain.Entities; + +/// A chapter: an ordered container of scenes plus its own planning fields. +public class Chapter +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ProjectId { get; set; } + public Project? Project { get; set; } + + /// Position in the manuscript, 1-based. + public int Number { get; set; } + + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + + /// Whose head we are in for this chapter. + public Guid? PovCharacterId { get; set; } + public Character? PovCharacter { get; set; } + + public string? Setting { get; set; } + public string? Notes { get; set; } + + public DraftStatus Status { get; set; } = DraftStatus.Planned; + public int? TargetWordCount { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public List Scenes { get; set; } = []; +} diff --git a/src/NovelSoftware.Domain/Entities/Character.cs b/src/NovelSoftware.Domain/Entities/Character.cs new file mode 100644 index 0000000..709e8b2 --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/Character.cs @@ -0,0 +1,62 @@ +namespace NovelSoftware.Domain.Entities; + +/// +/// A character dossier. Every field beyond is optional so a writer can +/// start with a name and fill the sheet in as the character comes into focus. +/// +public class Character +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ProjectId { get; set; } + public Project? Project { get; set; } + + public string Name { get; set; } = string.Empty; + public CharacterRole Role { get; set; } = CharacterRole.Supporting; + + public string? Age { get; set; } + public string? Pronouns { get; set; } + public string? Occupation { get; set; } + + public string? Appearance { get; set; } + public string? Personality { get; set; } + public string? Backstory { get; set; } + + /// What the character consciously wants. + public string? Want { get; set; } + + /// What the character actually needs — usually at odds with . + public string? Need { get; set; } + + public string? InternalConflict { get; set; } + public string? ExternalConflict { get; set; } + + /// How the character changes over the course of the book. + public string? ArcSummary { get; set; } + + /// Speech patterns, verbal tics, register — anything that makes dialogue sound like them. + public string? Voice { get; set; } + + public string? Notes { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public List Relationships { get; set; } = []; +} + +/// A directed relationship from one character to another. +public class CharacterRelationship +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid CharacterId { get; set; } + public Character? Character { get; set; } + + public Guid RelatedCharacterId { get; set; } + public Character? RelatedCharacter { get; set; } + + /// e.g. "sister", "rival", "former mentor". + public string RelationshipType { get; set; } = string.Empty; + + public string? Description { get; set; } +} diff --git a/src/NovelSoftware.Domain/Entities/OutlineNode.cs b/src/NovelSoftware.Domain/Entities/OutlineNode.cs new file mode 100644 index 0000000..a5200f7 --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/OutlineNode.cs @@ -0,0 +1,31 @@ +namespace NovelSoftware.Domain.Entities; + +/// +/// A node in the project's outline tree. Nodes are self-nesting, so the same structure +/// serves a three-act skeleton, a beat sheet, or a loose pile of scene ideas. +/// +public class OutlineNode +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ProjectId { get; set; } + public Project? Project { get; set; } + + public Guid? ParentId { get; set; } + public OutlineNode? Parent { get; set; } + public List Children { get; set; } = []; + + public OutlineNodeType NodeType { get; set; } = OutlineNodeType.Beat; + + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + + /// Position among siblings. Gaps are allowed; ordering is by this value then title. + public int SortOrder { get; set; } + + /// Optional link to the chapter that realises this outline node. + public Guid? ChapterId { get; set; } + public Chapter? Chapter { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/NovelSoftware.Domain/Entities/Project.cs b/src/NovelSoftware.Domain/Entities/Project.cs new file mode 100644 index 0000000..8ec6a55 --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/Project.cs @@ -0,0 +1,30 @@ +namespace NovelSoftware.Domain.Entities; + +/// A single novel and everything that belongs to it. +public class Project +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string Title { get; set; } = string.Empty; + public string? Author { get; set; } + public string? Genre { get; set; } + + /// One-sentence pitch. + public string? Logline { get; set; } + + /// Paragraph-length summary of the whole book. + public string? Synopsis { get; set; } + + /// Free-form notes on theme, tone, comparable titles, etc. + public string? Notes { get; set; } + + public int? TargetWordCount { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public List Characters { get; set; } = []; + public List Chapters { get; set; } = []; + public List OutlineNodes { get; set; } = []; + public List Conversations { get; set; } = []; +} diff --git a/src/NovelSoftware.Domain/Entities/Scene.cs b/src/NovelSoftware.Domain/Entities/Scene.cs new file mode 100644 index 0000000..9acaba9 --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/Scene.cs @@ -0,0 +1,41 @@ +namespace NovelSoftware.Domain.Entities; + +/// +/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent +/// works with when turning an outline into prose. +/// +public class Scene +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChapterId { get; set; } + public Chapter? Chapter { get; set; } + + /// Position within the chapter, 1-based. + public int SortOrder { get; set; } + + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + + /// What the POV character is trying to achieve. + public string? Goal { get; set; } + + /// What stands in the way. + public string? Conflict { get; set; } + + /// How it lands — and what it costs. + public string? Outcome { get; set; } + + public Guid? PovCharacterId { get; set; } + public Character? PovCharacter { get; set; } + + public string? Location { get; set; } + + /// The drafted prose, if any. + public string? Prose { get; set; } + + public int WordCount { get; set; } + public DraftStatus Status { get; set; } = DraftStatus.Planned; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/NovelSoftware.Domain/Enums.cs b/src/NovelSoftware.Domain/Enums.cs new file mode 100644 index 0000000..22f5b5d --- /dev/null +++ b/src/NovelSoftware.Domain/Enums.cs @@ -0,0 +1,45 @@ +namespace NovelSoftware.Domain; + +/// The role a character plays in the story. +public enum CharacterRole +{ + Protagonist, + Antagonist, + Deuteragonist, + Supporting, + Minor, + Mentor, + LoveInterest, + Foil +} + +/// +/// The kind of node in a project's outline tree. The tree is intentionally loose: +/// a writer can nest an Act under a Part, or skip straight to Beats. +/// +public enum OutlineNodeType +{ + Part, + Act, + Sequence, + Chapter, + Beat, + Note +} + +/// How far along a chapter or scene is in the drafting pipeline. +public enum DraftStatus +{ + Planned, + Outlined, + Drafted, + Revised, + Final +} + +/// Who produced a message in an agent conversation. +public enum AgentRole +{ + User, + Assistant +} diff --git a/src/NovelSoftware.Domain/NovelSoftware.Domain.csproj b/src/NovelSoftware.Domain/NovelSoftware.Domain.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/NovelSoftware.Domain/NovelSoftware.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/NovelSoftware.Infrastructure/Anthropic/AnthropicAgentModelClient.cs b/src/NovelSoftware.Infrastructure/Anthropic/AnthropicAgentModelClient.cs new file mode 100644 index 0000000..f106c8a --- /dev/null +++ b/src/NovelSoftware.Infrastructure/Anthropic/AnthropicAgentModelClient.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using Anthropic; +using Anthropic.Models.Messages; +using Microsoft.Extensions.Options; +using NovelSoftware.Application; +using NovelSoftware.Application.Agent; + +namespace NovelSoftware.Infrastructure.Anthropic; + +/// +/// Talks to the Anthropic Messages API. Translates between the application's +/// model-agnostic block types and the SDK's request/response shapes; the tool-use loop +/// itself lives in . +/// +public class AnthropicAgentModelClient(IOptions options) : IAgentModelClient +{ + private readonly AgentOptions _options = options.Value; + private AnthropicClient? _client; + + /// + /// Built on first use rather than at construction. This type is injected into the + /// agent service, which also serves read-only endpoints like listing conversations — + /// those should keep working on an install that has not set up a key yet. + /// + private AnthropicClient Client => _client ??= new AnthropicClient + { + ApiKey = _options.ApiKey + ?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") + ?? throw new AgentNotConfiguredException( + "No Anthropic API key configured. Set the ANTHROPIC_API_KEY environment " + + "variable or the Agent:ApiKey setting, then restart the API.") + }; + + public async Task CompleteAsync( + string systemPrompt, + IReadOnlyList messages, + IReadOnlyList tools, + CancellationToken ct = default) + { + var parameters = new MessageCreateParams + { + Model = _options.Model, + MaxTokens = _options.MaxTokens, + System = new List + { + // The system prompt is stable across a conversation, so cache it: every + // turn after the first reads it back at a tenth of the input price. + new() { Text = systemPrompt, CacheControl = new CacheControlEphemeral() } + }, + OutputConfig = new OutputConfig { Effort = ParseEffort(_options.Effort) }, + Tools = [.. tools.Select(ToSdkTool)], + Messages = [.. messages.Select(ToSdkMessage)] + }; + + var response = await Client.Messages.Create(parameters, cancellationToken: ct); + + return new AgentModelResponse( + [.. response.Content.Select(FromSdkBlock).OfType()], + response.StopReason?.ToString()); + } + + private static Effort ParseEffort(string effort) => effort.ToLowerInvariant() switch + { + "low" => Effort.Low, + "medium" => Effort.Medium, + "high" => Effort.High, + "max" => Effort.Max, + _ => Effort.High + }; + + private static ToolUnion ToSdkTool(AgentToolDefinition definition) + { + var properties = new Dictionary(); + if (definition.InputSchema.TryGetProperty("properties", out var props) + && props.ValueKind == JsonValueKind.Object) + { + foreach (var property in props.EnumerateObject()) + { + properties[property.Name] = property.Value; + } + } + + List required = []; + if (definition.InputSchema.TryGetProperty("required", out var req) + && req.ValueKind == JsonValueKind.Array) + { + required = [.. req.EnumerateArray().Select(r => r.GetString()!).Where(r => r is not null)]; + } + + return new Tool + { + Name = definition.Name, + Description = definition.Description, + InputSchema = new() + { + Properties = properties, + Required = required + } + }; + } + + private static MessageParam ToSdkMessage(AgentChatMessage message) => new() + { + Role = message.Role == "assistant" ? Role.Assistant : Role.User, + Content = new List([.. message.Content.Select(ToSdkBlock)]) + }; + + private static ContentBlockParam ToSdkBlock(AgentContentBlock block) => block switch + { + AgentTextBlock text => new TextBlockParam { Text = text.Text }, + + AgentToolUseBlock toolUse => new ToolUseBlockParam + { + ID = toolUse.Id, + Name = toolUse.Name, + Input = ToInputDictionary(toolUse.Input) + }, + + AgentToolResultBlock result => new ToolResultBlockParam + { + ToolUseID = result.ToolUseId, + Content = result.Content, + IsError = result.IsError + }, + + _ => throw new NotSupportedException($"Unsupported content block: {block.GetType().Name}") + }; + + private static AgentContentBlock? FromSdkBlock(ContentBlock block) + { + if (block.TryPickText(out TextBlock? text)) + { + return new AgentTextBlock(text!.Text); + } + + if (block.TryPickToolUse(out ToolUseBlock? toolUse)) + { + return new AgentToolUseBlock( + toolUse!.ID, + toolUse.Name, + JsonSerializer.SerializeToElement(toolUse.Input)); + } + + // Thinking blocks and any future block types carry nothing the loop acts on. + return null; + } + + private static Dictionary ToInputDictionary(JsonElement input) + { + var dictionary = new Dictionary(); + + if (input.ValueKind == JsonValueKind.Object) + { + foreach (var property in input.EnumerateObject()) + { + dictionary[property.Name] = property.Value; + } + } + + return dictionary; + } +} diff --git a/src/NovelSoftware.Infrastructure/DependencyInjection.cs b/src/NovelSoftware.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..4e82b90 --- /dev/null +++ b/src/NovelSoftware.Infrastructure/DependencyInjection.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NovelSoftware.Application; +using NovelSoftware.Application.Agent; +using NovelSoftware.Application.Services; +using NovelSoftware.Infrastructure.Anthropic; +using NovelSoftware.Infrastructure.Persistence; + +namespace NovelSoftware.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddNovelSoftware(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("Novel") + ?? "Data Source=novel.db"; + + services.AddDbContext(options => options.UseSqlite(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.Configure(configuration.GetSection(AgentOptions.SectionName)); + services.AddScoped(); + + return services; + } +} diff --git a/src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj b/src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj new file mode 100644 index 0000000..d3bc0df --- /dev/null +++ b/src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.Designer.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.Designer.cs new file mode 100644 index 0000000..2c8809c --- /dev/null +++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.Designer.cs @@ -0,0 +1,532 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NovelSoftware.Infrastructure.Persistence; + +#nullable disable + +namespace NovelSoftware.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260806023249_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId", "ParentId", "SortOrder"); + + b.ToTable("OutlineNodes"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("Conflict") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Goal") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .HasColumnType("TEXT"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Scenes"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b => + { + b.HasOne("NovelSoftware.Domain.Entities.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PovCharacter"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NovelSoftware.Domain.Entities.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("NovelSoftware.Domain.Entities.OutlineNode", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("OutlineNodes") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Parent"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter") + .WithMany("Scenes") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("PovCharacter"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => + { + b.Navigation("Scenes"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b => + { + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("OutlineNodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.cs new file mode 100644 index 0000000..026b6e6 --- /dev/null +++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.cs @@ -0,0 +1,339 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NovelSoftware.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Projects", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 300, nullable: false), + Author = table.Column(type: "TEXT", nullable: true), + Genre = table.Column(type: "TEXT", nullable: true), + Logline = table.Column(type: "TEXT", nullable: true), + Synopsis = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", nullable: true), + TargetWordCount = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Projects", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Characters", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Role = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Age = table.Column(type: "TEXT", nullable: true), + Pronouns = table.Column(type: "TEXT", nullable: true), + Occupation = table.Column(type: "TEXT", nullable: true), + Appearance = table.Column(type: "TEXT", nullable: true), + Personality = table.Column(type: "TEXT", nullable: true), + Backstory = table.Column(type: "TEXT", nullable: true), + Want = table.Column(type: "TEXT", nullable: true), + Need = table.Column(type: "TEXT", nullable: true), + InternalConflict = table.Column(type: "TEXT", nullable: true), + ExternalConflict = table.Column(type: "TEXT", nullable: true), + ArcSummary = table.Column(type: "TEXT", nullable: true), + Voice = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Characters", x => x.Id); + table.ForeignKey( + name: "FK_Characters_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Conversations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Conversations", x => x.Id); + table.ForeignKey( + name: "FK_Conversations_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Chapters", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + Number = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 300, nullable: false), + Summary = table.Column(type: "TEXT", nullable: true), + PovCharacterId = table.Column(type: "TEXT", nullable: true), + Setting = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", nullable: true), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + TargetWordCount = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Chapters", x => x.Id); + table.ForeignKey( + name: "FK_Chapters_Characters_PovCharacterId", + column: x => x.PovCharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Chapters_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CharacterRelationships", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CharacterId = table.Column(type: "TEXT", nullable: false), + RelatedCharacterId = table.Column(type: "TEXT", nullable: false), + RelationshipType = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Description = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CharacterRelationships", x => x.Id); + table.ForeignKey( + name: "FK_CharacterRelationships_Characters_CharacterId", + column: x => x.CharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CharacterRelationships_Characters_RelatedCharacterId", + column: x => x.RelatedCharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "AgentMessages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ConversationId = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Sequence = table.Column(type: "INTEGER", nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + ToolCallsJson = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AgentMessages", x => x.Id); + table.ForeignKey( + name: "FK_AgentMessages_Conversations_ConversationId", + column: x => x.ConversationId, + principalTable: "Conversations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OutlineNodes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + ParentId = table.Column(type: "TEXT", nullable: true), + NodeType = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Title = table.Column(type: "TEXT", maxLength: 300, nullable: false), + Summary = table.Column(type: "TEXT", nullable: true), + SortOrder = table.Column(type: "INTEGER", nullable: false), + ChapterId = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OutlineNodes", x => x.Id); + table.ForeignKey( + name: "FK_OutlineNodes_Chapters_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_OutlineNodes_OutlineNodes_ParentId", + column: x => x.ParentId, + principalTable: "OutlineNodes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_OutlineNodes_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Scenes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ChapterId = table.Column(type: "TEXT", nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 300, nullable: false), + Summary = table.Column(type: "TEXT", nullable: true), + Goal = table.Column(type: "TEXT", nullable: true), + Conflict = table.Column(type: "TEXT", nullable: true), + Outcome = table.Column(type: "TEXT", nullable: true), + PovCharacterId = table.Column(type: "TEXT", nullable: true), + Location = table.Column(type: "TEXT", nullable: true), + Prose = table.Column(type: "TEXT", nullable: true), + WordCount = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Scenes", x => x.Id); + table.ForeignKey( + name: "FK_Scenes_Chapters_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Scenes_Characters_PovCharacterId", + column: x => x.PovCharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_AgentMessages_ConversationId_Sequence", + table: "AgentMessages", + columns: new[] { "ConversationId", "Sequence" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Chapters_PovCharacterId", + table: "Chapters", + column: "PovCharacterId"); + + migrationBuilder.CreateIndex( + name: "IX_Chapters_ProjectId_Number", + table: "Chapters", + columns: new[] { "ProjectId", "Number" }); + + migrationBuilder.CreateIndex( + name: "IX_CharacterRelationships_CharacterId", + table: "CharacterRelationships", + column: "CharacterId"); + + migrationBuilder.CreateIndex( + name: "IX_CharacterRelationships_RelatedCharacterId", + table: "CharacterRelationships", + column: "RelatedCharacterId"); + + migrationBuilder.CreateIndex( + name: "IX_Characters_ProjectId", + table: "Characters", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_Conversations_ProjectId", + table: "Conversations", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_OutlineNodes_ChapterId", + table: "OutlineNodes", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_OutlineNodes_ParentId", + table: "OutlineNodes", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_OutlineNodes_ProjectId_ParentId_SortOrder", + table: "OutlineNodes", + columns: new[] { "ProjectId", "ParentId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_Scenes_ChapterId_SortOrder", + table: "Scenes", + columns: new[] { "ChapterId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_Scenes_PovCharacterId", + table: "Scenes", + column: "PovCharacterId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AgentMessages"); + + migrationBuilder.DropTable( + name: "CharacterRelationships"); + + migrationBuilder.DropTable( + name: "OutlineNodes"); + + migrationBuilder.DropTable( + name: "Scenes"); + + migrationBuilder.DropTable( + name: "Conversations"); + + migrationBuilder.DropTable( + name: "Chapters"); + + migrationBuilder.DropTable( + name: "Characters"); + + migrationBuilder.DropTable( + name: "Projects"); + } + } +} diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs new file mode 100644 index 0000000..5e9fb14 --- /dev/null +++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs @@ -0,0 +1,529 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NovelSoftware.Infrastructure.Persistence; + +#nullable disable + +namespace NovelSoftware.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(NovelDbContext))] + partial class NovelDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId", "ParentId", "SortOrder"); + + b.ToTable("OutlineNodes"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("Conflict") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Goal") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .HasColumnType("TEXT"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Scenes"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b => + { + b.HasOne("NovelSoftware.Domain.Entities.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PovCharacter"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NovelSoftware.Domain.Entities.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("NovelSoftware.Domain.Entities.OutlineNode", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") + .WithMany("OutlineNodes") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Parent"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b => + { + b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter") + .WithMany("Scenes") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("PovCharacter"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => + { + b.Navigation("Scenes"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b => + { + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("OutlineNodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs b/src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs new file mode 100644 index 0000000..d7423fa --- /dev/null +++ b/src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs @@ -0,0 +1,122 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NovelSoftware.Application; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Infrastructure.Persistence; + +/// +/// Stores a as UTC ticks. SQLite has no native type for it +/// and refuses to ORDER BY the default text form, which every "most recently updated +/// first" listing depends on. The domain only ever writes UtcNow, so normalising to UTC +/// loses nothing. +/// +internal sealed class UtcTicksConverter() + : ValueConverter( + value => value.UtcTicks, + ticks => new DateTimeOffset(ticks, TimeSpan.Zero)); + +public class NovelDbContext(DbContextOptions options) + : DbContext(options), INovelDbContext +{ + public DbSet Projects => Set(); + public DbSet Characters => Set(); + public DbSet CharacterRelationships => Set(); + public DbSet OutlineNodes => Set(); + public DbSet Chapters => Set(); + public DbSet Scenes => Set(); + public DbSet Conversations => Set(); + public DbSet AgentMessages => Set(); + + Task INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => + base.SaveChangesAsync(cancellationToken); + + protected override void ConfigureConventions(ModelConfigurationBuilder builder) => + builder.Properties().HaveConversion(); + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity(entity => + { + entity.Property(p => p.Title).IsRequired().HasMaxLength(300); + entity.HasMany(p => p.Characters).WithOne(c => c.Project!) + .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(p => p.Chapters).WithOne(c => c.Project!) + .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(p => p.OutlineNodes).WithOne(n => n.Project!) + .HasForeignKey(n => n.ProjectId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(p => p.Conversations).WithOne(c => c.Project!) + .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.Property(c => c.Name).IsRequired().HasMaxLength(200); + entity.Property(c => c.Role).HasConversion().HasMaxLength(32); + entity.HasIndex(c => c.ProjectId); + + entity.HasMany(c => c.Relationships).WithOne(r => r.Character!) + .HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120); + + // Restrict on the inverse side: deleting a character should not silently take + // the other character's relationship rows with it via a second cascade path, + // which SQLite rejects as a multiple-cascade cycle. + entity.HasOne(r => r.RelatedCharacter).WithMany() + .HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(n => n.Title).IsRequired().HasMaxLength(300); + entity.Property(n => n.NodeType).HasConversion().HasMaxLength(32); + entity.HasIndex(n => new { n.ProjectId, n.ParentId, n.SortOrder }); + + entity.HasOne(n => n.Parent).WithMany(n => n.Children) + .HasForeignKey(n => n.ParentId).OnDelete(DeleteBehavior.Restrict); + + entity.HasOne(n => n.Chapter).WithMany() + .HasForeignKey(n => n.ChapterId).OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(c => c.Title).IsRequired().HasMaxLength(300); + entity.Property(c => c.Status).HasConversion().HasMaxLength(32); + entity.HasIndex(c => new { c.ProjectId, c.Number }); + + entity.HasOne(c => c.PovCharacter).WithMany() + .HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull); + + entity.HasMany(c => c.Scenes).WithOne(s => s.Chapter!) + .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.Property(s => s.Title).IsRequired().HasMaxLength(300); + entity.Property(s => s.Status).HasConversion().HasMaxLength(32); + entity.HasIndex(s => new { s.ChapterId, s.SortOrder }); + + entity.HasOne(s => s.PovCharacter).WithMany() + .HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(c => c.Title).IsRequired().HasMaxLength(200); + entity.HasMany(c => c.Messages).WithOne(m => m.Conversation!) + .HasForeignKey(m => m.ConversationId).OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.Property(m => m.Role).HasConversion().HasMaxLength(16); + entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique(); + }); + } +} diff --git a/src/NovelSoftware.Mcp/NovelApiClient.cs b/src/NovelSoftware.Mcp/NovelApiClient.cs new file mode 100644 index 0000000..b35be8b --- /dev/null +++ b/src/NovelSoftware.Mcp/NovelApiClient.cs @@ -0,0 +1,104 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace NovelSoftware.Mcp; + +/// +/// Thin wrapper over the NovelSoftware REST API. The MCP server deliberately owns no +/// domain logic of its own — it is a second front end onto the same API the web client +/// uses, so an edit made from Claude Code and one made in the browser are the same edit. +/// +public class NovelApiClient(HttpClient http) +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public Task GetAsync(string path, CancellationToken ct = default) => + SendAsync(new HttpRequestMessage(HttpMethod.Get, path), ct); + + public Task PostAsync(string path, object body, CancellationToken ct = default) => + SendAsync(new HttpRequestMessage(HttpMethod.Post, path) + { + Content = JsonContent.Create(body, options: Options) + }, ct); + + public Task PatchAsync(string path, object body, CancellationToken ct = default) => + SendAsync(new HttpRequestMessage(HttpMethod.Patch, path) + { + Content = JsonContent.Create(body, options: Options) + }, ct); + + public Task DeleteAsync(string path, CancellationToken ct = default) => + SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct); + + /// + /// Sends the request and shapes the outcome as a tool result. Failures come back as + /// `isError` results carrying the API's own message, rather than as exceptions the + /// SDK would flatten into "an error occurred" — the model can act on the former. + /// + private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + HttpResponseMessage response; + try + { + response = await http.SendAsync(request, ct); + } + catch (HttpRequestException ex) + { + // The API not being up is the most common failure here, and a bare connection + // exception tells the model nothing actionable. + return Error($"Could not reach the NovelSoftware API at {http.BaseAddress}. Is it running? ({ex.Message})"); + } + + var body = await response.Content.ReadAsStringAsync(ct); + + if (response.IsSuccessStatusCode) + { + return Ok(string.IsNullOrWhiteSpace(body) ? "{\"ok\":true}" : Prettify(body)); + } + + var detail = TryReadProblemDetail(body) ?? body; + return Error(response.StatusCode switch + { + HttpStatusCode.NotFound => $"Not found: {detail}", + HttpStatusCode.BadRequest => $"Rejected: {detail}", + _ => $"API returned {(int)response.StatusCode}: {detail}" + }); + } + + private static CallToolResult Ok(string text) => + new() { Content = [new TextContentBlock { Text = text }] }; + + private static CallToolResult Error(string message) => + new() { Content = [new TextContentBlock { Text = message }], IsError = true }; + + /// Reformats the API's compact JSON so tool output reads well in a transcript. + private static string Prettify(string json) + { + try + { + return JsonSerializer.Serialize(JsonSerializer.Deserialize(json), Options); + } + catch (JsonException) + { + return json; + } + } + + private static string? TryReadProblemDetail(string body) + { + try + { + var problem = JsonSerializer.Deserialize(body); + return problem.TryGetProperty("detail", out var detail) ? detail.GetString() : null; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/NovelSoftware.Mcp/NovelSoftware.Mcp.csproj b/src/NovelSoftware.Mcp/NovelSoftware.Mcp.csproj new file mode 100644 index 0000000..d3115be --- /dev/null +++ b/src/NovelSoftware.Mcp/NovelSoftware.Mcp.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/src/NovelSoftware.Mcp/Program.cs b/src/NovelSoftware.Mcp/Program.cs new file mode 100644 index 0000000..dc3f351 --- /dev/null +++ b/src/NovelSoftware.Mcp/Program.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NovelSoftware.Mcp; + +var builder = Host.CreateApplicationBuilder(args); + +// stdout is the MCP transport. Anything written there that is not a JSON-RPC frame +// corrupts the stream, so every log line goes to stderr instead. +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace); +builder.Logging.SetMinimumLevel(LogLevel.Warning); + +var apiBaseUrl = builder.Configuration["NOVELSOFTWARE_API_URL"] ?? "http://localhost:5080"; + +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(apiBaseUrl); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + +await builder.Build().RunAsync(); diff --git a/src/NovelSoftware.Mcp/Tools/CharacterTools.cs b/src/NovelSoftware.Mcp/Tools/CharacterTools.cs new file mode 100644 index 0000000..c79aef6 --- /dev/null +++ b/src/NovelSoftware.Mcp/Tools/CharacterTools.cs @@ -0,0 +1,120 @@ +using System.ComponentModel; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace NovelSoftware.Mcp.Tools; + +[McpServerToolType] +public static class CharacterTools +{ + [McpServerTool(Name = "list_characters")] + [Description("List a project's character dossiers in full, including their relationships.")] + public static Task ListCharacters( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + CancellationToken ct) => + api.GetAsync($"/api/projects/{projectId}/characters", ct); + + [McpServerTool(Name = "get_character")] + [Description("Read one character's dossier.")] + public static Task GetCharacter( + NovelApiClient api, + [Description("The character's id.")] Guid characterId, + CancellationToken ct) => + api.GetAsync($"/api/characters/{characterId}", ct); + + [McpServerTool(Name = "create_character")] + [Description("Add a character dossier to a project. Name is the only requirement — leave a field " + + "blank when the writer has not decided it yet rather than inventing detail.")] + public static Task CreateCharacter( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + [Description("The character's name.")] string name, + CancellationToken ct, + [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] + string? role = null, + [Description("Age, exact or approximate.")] string? age = null, + [Description("The pronouns this character uses.")] string? pronouns = null, + [Description("What they do.")] string? occupation = null, + [Description("How they look.")] string? appearance = null, + [Description("Temperament, habits, how they treat people.")] string? personality = null, + [Description("History that shapes who they are now.")] string? backstory = null, + [Description("What they consciously pursue.")] string? want = null, + [Description("What they actually need, usually at odds with what they want.")] string? need = null, + [Description("The war inside them.")] string? internalConflict = null, + [Description("What in the world opposes them.")] string? externalConflict = null, + [Description("How they change over the course of the book.")] string? arcSummary = null, + [Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null, + [Description("Anything else worth recording.")] string? notes = null) => + api.PostAsync($"/api/projects/{projectId}/characters", new + { + name, + role = role ?? "Supporting", + age, + pronouns, + occupation, + appearance, + personality, + backstory, + want, + need, + internalConflict, + externalConflict, + arcSummary, + voice, + notes + }, ct); + + [McpServerTool(Name = "update_character")] + [Description("Revise an existing character dossier. Only the fields you supply change.")] + public static Task UpdateCharacter( + NovelApiClient api, + [Description("The character's id.")] Guid characterId, + CancellationToken ct, + [Description("New name.")] string? name = null, + [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] + string? role = null, + [Description("Age, exact or approximate.")] string? age = null, + [Description("The pronouns this character uses.")] string? pronouns = null, + [Description("What they do.")] string? occupation = null, + [Description("How they look.")] string? appearance = null, + [Description("Temperament, habits, how they treat people.")] string? personality = null, + [Description("History that shapes who they are now.")] string? backstory = null, + [Description("What they consciously pursue.")] string? want = null, + [Description("What they actually need.")] string? need = null, + [Description("The war inside them.")] string? internalConflict = null, + [Description("What in the world opposes them.")] string? externalConflict = null, + [Description("How they change over the course of the book.")] string? arcSummary = null, + [Description("Speech patterns and register.")] string? voice = null, + [Description("Anything else worth recording.")] string? notes = null) => + api.PatchAsync($"/api/characters/{characterId}", new + { + name, + role, + age, + pronouns, + occupation, + appearance, + personality, + backstory, + want, + need, + internalConflict, + externalConflict, + arcSummary, + voice, + notes + }, ct); + + [McpServerTool(Name = "relate_characters")] + [Description("Record a relationship from one character to another in the same project.")] + public static Task RelateCharacters( + NovelApiClient api, + [Description("Id of the character the relationship belongs to.")] Guid characterId, + [Description("Id of the character they are related to.")] Guid relatedCharacterId, + [Description("How they are related, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType, + CancellationToken ct, + [Description("What the relationship is like, and where it is headed.")] string? description = null) => + api.PostAsync($"/api/characters/{characterId}/relationships", + new { relatedCharacterId, relationshipType, description }, ct); +} diff --git a/src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs b/src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs new file mode 100644 index 0000000..e9e87aa --- /dev/null +++ b/src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs @@ -0,0 +1,136 @@ +using System.ComponentModel; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace NovelSoftware.Mcp.Tools; + +[McpServerToolType] +public static class ManuscriptTools +{ + [McpServerTool(Name = "list_chapters")] + [Description("List a project's chapters in manuscript order, with scene and word counts.")] + public static Task ListChapters( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + CancellationToken ct) => + api.GetAsync($"/api/projects/{projectId}/chapters", ct); + + [McpServerTool(Name = "get_chapter")] + [Description("Read one chapter in full, including every scene and any drafted prose.")] + public static Task GetChapter( + NovelApiClient api, + [Description("The chapter's id.")] Guid chapterId, + CancellationToken ct) => + api.GetAsync($"/api/chapters/{chapterId}", ct); + + [McpServerTool(Name = "create_chapter")] + [Description("Add a chapter to a project. It goes at the end of the manuscript unless you supply a number.")] + public static Task CreateChapter( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + [Description("Chapter title.")] string title, + CancellationToken ct, + [Description("Position in the manuscript, 1-based.")] int? number = null, + [Description("What the chapter covers.")] string? summary = null, + [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, + [Description("Where and when the chapter takes place.")] string? setting = null, + [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, + [Description("Target length in words.")] int? targetWordCount = null) => + api.PostAsync($"/api/projects/{projectId}/chapters", new + { + title, + number, + summary, + povCharacterId, + setting, + status = status ?? "Planned", + targetWordCount + }, ct); + + [McpServerTool(Name = "update_chapter")] + [Description("Revise a chapter's title, number, summary, POV character, setting, notes or status.")] + public static Task UpdateChapter( + NovelApiClient api, + [Description("The chapter's id.")] Guid chapterId, + CancellationToken ct, + [Description("New title.")] string? title = null, + [Description("Position in the manuscript.")] int? number = null, + [Description("What the chapter covers.")] string? summary = null, + [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, + [Description("Where and when the chapter takes place.")] string? setting = null, + [Description("Anything else worth recording.")] string? notes = null, + [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, + [Description("Target length in words.")] int? targetWordCount = null) => + api.PatchAsync($"/api/chapters/{chapterId}", + new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount }, ct); + + [McpServerTool(Name = "list_scenes")] + [Description("List a chapter's scenes in order.")] + public static Task ListScenes( + NovelApiClient api, + [Description("The chapter's id.")] Guid chapterId, + CancellationToken ct) => + api.GetAsync($"/api/chapters/{chapterId}/scenes", ct); + + [McpServerTool(Name = "create_scene")] + [Description("Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene " + + "draftable later, so fill those in when there is enough to work with.")] + public static Task CreateScene( + NovelApiClient api, + [Description("The chapter's id.")] Guid chapterId, + [Description("Scene title.")] string title, + CancellationToken ct, + [Description("Position within the chapter. Appended to the end when omitted.")] int? sortOrder = null, + [Description("What happens in the scene.")] string? summary = null, + [Description("What the POV character is trying to achieve.")] string? goal = null, + [Description("What stands in the way.")] string? conflict = null, + [Description("How it lands, and what it costs.")] string? outcome = null, + [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, + [Description("Where the scene takes place.")] string? location = null, + [Description("Drafted prose for the scene, if you are writing it now.")] string? prose = null, + [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null) => + api.PostAsync($"/api/chapters/{chapterId}/scenes", new + { + title, + sortOrder, + summary, + goal, + conflict, + outcome, + povCharacterId, + location, + prose, + status = status ?? "Planned" + }, ct); + + [McpServerTool(Name = "update_scene")] + [Description("Revise a scene. Supplying 'prose' writes or replaces the scene's draft text and " + + "recomputes its word count.")] + public static Task UpdateScene( + NovelApiClient api, + [Description("The scene's id.")] Guid sceneId, + CancellationToken ct, + [Description("New title.")] string? title = null, + [Description("Position within the chapter.")] int? sortOrder = null, + [Description("What happens in the scene.")] string? summary = null, + [Description("What the POV character is trying to achieve.")] string? goal = null, + [Description("What stands in the way.")] string? conflict = null, + [Description("How it lands, and what it costs.")] string? outcome = null, + [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, + [Description("Where the scene takes place.")] string? location = null, + [Description("Drafted prose for the scene.")] string? prose = null, + [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null) => + api.PatchAsync($"/api/scenes/{sceneId}", new + { + title, + sortOrder, + summary, + goal, + conflict, + outcome, + povCharacterId, + location, + prose, + status + }, ct); +} diff --git a/src/NovelSoftware.Mcp/Tools/OutlineTools.cs b/src/NovelSoftware.Mcp/Tools/OutlineTools.cs new file mode 100644 index 0000000..090d246 --- /dev/null +++ b/src/NovelSoftware.Mcp/Tools/OutlineTools.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace NovelSoftware.Mcp.Tools; + +[McpServerToolType] +public static class OutlineTools +{ + [McpServerTool(Name = "get_outline")] + [Description("Read a project's outline as a nested tree of parts, acts, sequences and beats.")] + public static Task GetOutline( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + CancellationToken ct) => + api.GetAsync($"/api/projects/{projectId}/outline", ct); + + [McpServerTool(Name = "create_outline_node")] + [Description("Add a node to a project's outline. Pass parentId to nest it under another node; " + + "omit it for a top-level node.")] + public static Task CreateOutlineNode( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + [Description("Short label for the node.")] string title, + CancellationToken ct, + [Description("Part, Act, Sequence, Chapter, Beat or Note.")] string? nodeType = null, + [Description("Id of the parent node, if nesting.")] Guid? parentId = null, + [Description("What happens here, in a sentence or two.")] string? summary = null, + [Description("Position among siblings. Appended to the end when omitted.")] int? sortOrder = null, + [Description("Id of the chapter that realises this node, if one exists.")] Guid? chapterId = null) => + api.PostAsync($"/api/projects/{projectId}/outline", new + { + title, + nodeType = nodeType ?? "Beat", + parentId, + summary, + sortOrder, + chapterId + }, ct); + + [McpServerTool(Name = "update_outline_node")] + [Description("Revise an outline node's title, type, summary, position or linked chapter.")] + public static Task UpdateOutlineNode( + NovelApiClient api, + [Description("The node's id.")] Guid nodeId, + CancellationToken ct, + [Description("New title.")] string? title = null, + [Description("Part, Act, Sequence, Chapter, Beat or Note.")] string? nodeType = null, + [Description("What happens here.")] string? summary = null, + [Description("Position among siblings.")] int? sortOrder = null, + [Description("Id of the chapter that realises this node.")] Guid? chapterId = null) => + api.PatchAsync($"/api/outline/{nodeId}", new { title, nodeType, summary, sortOrder, chapterId }, ct); + + [McpServerTool(Name = "move_outline_node")] + [Description("Reparent or reorder an outline node. Pass a null parentId to move it to the top level.")] + public static Task MoveOutlineNode( + NovelApiClient api, + [Description("The node's id.")] Guid nodeId, + [Description("Position among its new siblings.")] int sortOrder, + CancellationToken ct, + [Description("Id of the new parent node, or null for the top level.")] Guid? parentId = null) => + api.PostAsync($"/api/outline/{nodeId}/move", new { parentId, sortOrder }, ct); + + [McpServerTool(Name = "delete_outline_node")] + [Description("Delete an outline node and everything nested beneath it. This cannot be undone — " + + "confirm with the writer before calling it.")] + public static Task DeleteOutlineNode( + NovelApiClient api, + [Description("The node's id.")] Guid nodeId, + CancellationToken ct) => + api.DeleteAsync($"/api/outline/{nodeId}", ct); +} diff --git a/src/NovelSoftware.Mcp/Tools/ProjectTools.cs b/src/NovelSoftware.Mcp/Tools/ProjectTools.cs new file mode 100644 index 0000000..7756225 --- /dev/null +++ b/src/NovelSoftware.Mcp/Tools/ProjectTools.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace NovelSoftware.Mcp.Tools; + +[McpServerToolType] +public static class ProjectTools +{ + [McpServerTool(Name = "list_projects")] + [Description("List every novel project, with counts of characters, chapters and drafted words. " + + "Start here to find the project id everything else needs.")] + public static Task ListProjects(NovelApiClient api, CancellationToken ct) => + api.GetAsync("/api/projects", ct); + + [McpServerTool(Name = "get_project_brief")] + [Description("Read a project's title, author, genre, logline, synopsis, notes and word-count target.")] + public static Task GetProject( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + CancellationToken ct) => + api.GetAsync($"/api/projects/{projectId}", ct); + + [McpServerTool(Name = "create_project")] + [Description("Create a new novel project.")] + public static Task CreateProject( + NovelApiClient api, + [Description("Working title.")] string title, + CancellationToken ct, + [Description("Author name.")] string? author = null, + [Description("Genre or category.")] string? genre = null, + [Description("One-sentence pitch.")] string? logline = null, + [Description("Paragraph-length summary of the whole book.")] string? synopsis = null, + [Description("Target manuscript length in words.")] int? targetWordCount = null) => + api.PostAsync("/api/projects", new { title, author, genre, logline, synopsis, targetWordCount }, ct); + + [McpServerTool(Name = "update_project_brief")] + [Description("Revise a project's top-level fields. Only the fields you supply change; " + + "pass an empty string to clear one.")] + public static Task UpdateProject( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + CancellationToken ct, + [Description("New title.")] string? title = null, + [Description("Author name.")] string? author = null, + [Description("Genre or category.")] string? genre = null, + [Description("One-sentence pitch.")] string? logline = null, + [Description("Paragraph-length summary of the whole book.")] string? synopsis = null, + [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, + [Description("Target manuscript length in words.")] int? targetWordCount = null) => + api.PatchAsync($"/api/projects/{projectId}", + new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); +} diff --git a/src/NovelSoftware.Web/.gitignore b/src/NovelSoftware.Web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/src/NovelSoftware.Web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/src/NovelSoftware.Web/.oxlintrc.json b/src/NovelSoftware.Web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/src/NovelSoftware.Web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/src/NovelSoftware.Web/README.md b/src/NovelSoftware.Web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/src/NovelSoftware.Web/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/src/NovelSoftware.Web/index.html b/src/NovelSoftware.Web/index.html new file mode 100644 index 0000000..8ff3f6c --- /dev/null +++ b/src/NovelSoftware.Web/index.html @@ -0,0 +1,12 @@ + + + + + + Novel Software + + +
+ + + diff --git a/src/NovelSoftware.Web/package-lock.json b/src/NovelSoftware.Web/package-lock.json new file mode 100644 index 0000000..517bb43 --- /dev/null +++ b/src/NovelSoftware.Web/package-lock.json @@ -0,0 +1,2053 @@ +{ + "name": "novelsoftware-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "novelsoftware-web", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "playwright": "^1.62.1", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", + "integrity": "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.77.0.tgz", + "integrity": "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.77.0.tgz", + "integrity": "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.77.0.tgz", + "integrity": "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.77.0.tgz", + "integrity": "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.77.0.tgz", + "integrity": "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.77.0.tgz", + "integrity": "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.77.0.tgz", + "integrity": "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.77.0.tgz", + "integrity": "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.77.0.tgz", + "integrity": "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.77.0.tgz", + "integrity": "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.77.0.tgz", + "integrity": "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.77.0.tgz", + "integrity": "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.77.0.tgz", + "integrity": "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.77.0.tgz", + "integrity": "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.77.0.tgz", + "integrity": "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.77.0.tgz", + "integrity": "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.77.0.tgz", + "integrity": "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.77.0.tgz", + "integrity": "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", + "integrity": "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.77.0", + "@oxlint/binding-android-arm64": "1.77.0", + "@oxlint/binding-darwin-arm64": "1.77.0", + "@oxlint/binding-darwin-x64": "1.77.0", + "@oxlint/binding-freebsd-x64": "1.77.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", + "@oxlint/binding-linux-arm-musleabihf": "1.77.0", + "@oxlint/binding-linux-arm64-gnu": "1.77.0", + "@oxlint/binding-linux-arm64-musl": "1.77.0", + "@oxlint/binding-linux-ppc64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-musl": "1.77.0", + "@oxlint/binding-linux-s390x-gnu": "1.77.0", + "@oxlint/binding-linux-x64-gnu": "1.77.0", + "@oxlint/binding-linux-x64-musl": "1.77.0", + "@oxlint/binding-openharmony-arm64": "1.77.0", + "@oxlint/binding-win32-arm64-msvc": "1.77.0", + "@oxlint/binding-win32-ia32-msvc": "1.77.0", + "@oxlint/binding-win32-x64-msvc": "1.77.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/src/NovelSoftware.Web/package.json b/src/NovelSoftware.Web/package.json new file mode 100644 index 0000000..dacf4ac --- /dev/null +++ b/src/NovelSoftware.Web/package.json @@ -0,0 +1,30 @@ +{ + "name": "novelsoftware-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "playwright": "^1.62.1", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } +} diff --git a/src/NovelSoftware.Web/public/favicon.svg b/src/NovelSoftware.Web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/src/NovelSoftware.Web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/NovelSoftware.Web/public/icons.svg b/src/NovelSoftware.Web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/src/NovelSoftware.Web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NovelSoftware.Web/src/App.tsx b/src/NovelSoftware.Web/src/App.tsx new file mode 100644 index 0000000..739b91c --- /dev/null +++ b/src/NovelSoftware.Web/src/App.tsx @@ -0,0 +1,26 @@ +import { Route, Routes } from 'react-router-dom' +import ProjectsPage from './pages/ProjectsPage' +import ProjectLayout from './pages/ProjectLayout' +import OverviewPage from './pages/OverviewPage' +import CharactersPage from './pages/CharactersPage' +import OutlinePage from './pages/OutlinePage' +import ChaptersPage from './pages/ChaptersPage' +import ChapterPage from './pages/ChapterPage' +import AgentPage from './pages/AgentPage' + +export default function App() { + return ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ) +} diff --git a/src/NovelSoftware.Web/src/api/client.ts b/src/NovelSoftware.Web/src/api/client.ts new file mode 100644 index 0000000..c7fa1d8 --- /dev/null +++ b/src/NovelSoftware.Web/src/api/client.ts @@ -0,0 +1,48 @@ +const BASE = import.meta.env.VITE_API_BASE ?? '' + +/** An API error carrying the ProblemDetails message so the UI can show something useful. */ +export class ApiError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${BASE}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, + }) + + if (!response.ok) { + let detail = response.statusText + try { + const problem = await response.json() + detail = problem.detail ?? problem.title ?? detail + } catch { + // Non-JSON error body — the status text is the best we have. + } + throw new ApiError(detail, response.status) + } + + if (response.status === 204) { + return undefined as T + } + + return response.json() as Promise +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body ?? {}) }), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +} diff --git a/src/NovelSoftware.Web/src/api/hooks.ts b/src/NovelSoftware.Web/src/api/hooks.ts new file mode 100644 index 0000000..9f6cd7e --- /dev/null +++ b/src/NovelSoftware.Web/src/api/hooks.ts @@ -0,0 +1,231 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from './client' +import type { + AgentTurn, + Chapter, + ChapterSummary, + Character, + Conversation, + ConversationSummary, + OutlineNode, + Project, + ProjectSummary, + Scene, +} from './types' + +export const keys = { + projects: ['projects'] as const, + project: (id: string) => ['projects', id] as const, + characters: (projectId: string) => ['projects', projectId, 'characters'] as const, + outline: (projectId: string) => ['projects', projectId, 'outline'] as const, + chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const, + chapter: (id: string) => ['chapters', id] as const, + conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const, + conversation: (id: string) => ['conversations', id] as const, +} + +// --- Projects --------------------------------------------------------------- + +export const useProjects = () => + useQuery({ queryKey: keys.projects, queryFn: () => api.get('/api/projects') }) + +export const useProject = (id: string) => + useQuery({ queryKey: keys.project(id), queryFn: () => api.get(`/api/projects/${id}`) }) + +export function useCreateProject() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) => + api.post('/api/projects', body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }), + }) +} + +export function useUpdateProject(id: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Partial) => api.patch(`/api/projects/${id}`, body), + onSuccess: (updated) => { + qc.setQueryData(keys.project(id), updated) + qc.invalidateQueries({ queryKey: keys.projects }) + }, + }) +} + +export function useDeleteProject() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/projects/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }), + }) +} + +// --- Characters ------------------------------------------------------------- + +export const useCharacters = (projectId: string) => + useQuery({ + queryKey: keys.characters(projectId), + queryFn: () => api.get(`/api/projects/${projectId}/characters`), + }) + +export function useCreateCharacter(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Partial & { name: string }) => + api.post(`/api/projects/${projectId}/characters`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +export function useUpdateCharacter(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: Partial & { id: string }) => + api.patch(`/api/characters/${id}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +export function useDeleteCharacter(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/characters/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +// --- Outline ---------------------------------------------------------------- + +export const useOutline = (projectId: string) => + useQuery({ + queryKey: keys.outline(projectId), + queryFn: () => api.get(`/api/projects/${projectId}/outline`), + }) + +export function useCreateOutlineNode(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Partial & { title: string }) => + api.post(`/api/projects/${projectId}/outline`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), + }) +} + +export function useUpdateOutlineNode(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: Partial & { id: string }) => + api.patch(`/api/outline/${id}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), + }) +} + +export function useDeleteOutlineNode(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/outline/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), + }) +} + +// --- Chapters and scenes ---------------------------------------------------- + +export const useChapters = (projectId: string) => + useQuery({ + queryKey: keys.chapters(projectId), + queryFn: () => api.get(`/api/projects/${projectId}/chapters`), + }) + +export const useChapter = (id: string | undefined) => + useQuery({ + queryKey: keys.chapter(id ?? ''), + queryFn: () => api.get(`/api/chapters/${id}`), + enabled: Boolean(id), + }) + +export function useCreateChapter(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Partial & { title: string }) => + api.post(`/api/projects/${projectId}/chapters`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }), + }) +} + +export function useUpdateChapter(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: Partial & { id: string }) => + api.patch(`/api/chapters/${id}`, body), + onSuccess: (updated) => { + qc.setQueryData(keys.chapter(updated.id), updated) + qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) + }, + }) +} + +export function useDeleteChapter(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/chapters/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }), + }) +} + +export function useCreateScene(chapterId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Partial & { title: string }) => + api.post(`/api/chapters/${chapterId}/scenes`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), + }) +} + +export function useUpdateScene(chapterId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: Partial & { id: string }) => + api.patch(`/api/scenes/${id}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), + }) +} + +export function useDeleteScene(chapterId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/scenes/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), + }) +} + +// --- Agent ------------------------------------------------------------------ + +export const useConversations = (projectId: string) => + useQuery({ + queryKey: keys.conversations(projectId), + queryFn: () => api.get(`/api/projects/${projectId}/agent/conversations`), + }) + +export const useConversation = (id: string | undefined) => + useQuery({ + queryKey: keys.conversation(id ?? ''), + queryFn: () => api.get(`/api/conversations/${id}`), + enabled: Boolean(id), + }) + +export function useSendAgentMessage(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { message: string; conversationId?: string }) => + api.post(`/api/projects/${projectId}/agent/messages`, body), + onSuccess: (turn) => { + qc.invalidateQueries({ queryKey: keys.conversations(projectId) }) + qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) }) + // The agent edits project data through its tools, so anything on screen may be stale. + qc.invalidateQueries({ queryKey: keys.characters(projectId) }) + qc.invalidateQueries({ queryKey: keys.outline(projectId) }) + qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) + qc.invalidateQueries({ queryKey: keys.project(projectId) }) + }, + }) +} diff --git a/src/NovelSoftware.Web/src/api/types.ts b/src/NovelSoftware.Web/src/api/types.ts new file mode 100644 index 0000000..19ee5a4 --- /dev/null +++ b/src/NovelSoftware.Web/src/api/types.ts @@ -0,0 +1,175 @@ +// Mirrors the DTOs in NovelSoftware.Application.Dtos. Enums travel as their names. + +export type CharacterRole = + | 'Protagonist' + | 'Antagonist' + | 'Deuteragonist' + | 'Supporting' + | 'Minor' + | 'Mentor' + | 'LoveInterest' + | 'Foil' + +export const characterRoles: CharacterRole[] = [ + 'Protagonist', + 'Antagonist', + 'Deuteragonist', + 'Supporting', + 'Minor', + 'Mentor', + 'LoveInterest', + 'Foil', +] + +export type OutlineNodeType = 'Part' | 'Act' | 'Sequence' | 'Chapter' | 'Beat' | 'Note' + +export const outlineNodeTypes: OutlineNodeType[] = [ + 'Part', + 'Act', + 'Sequence', + 'Chapter', + 'Beat', + 'Note', +] + +export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Final' + +export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] + +export interface ProjectSummary { + id: string + title: string + author: string | null + genre: string | null + logline: string | null + targetWordCount: number | null + characterCount: number + chapterCount: number + wordCount: number + updatedAt: string +} + +export interface Project { + id: string + title: string + author: string | null + genre: string | null + logline: string | null + synopsis: string | null + notes: string | null + targetWordCount: number | null + createdAt: string + updatedAt: string +} + +export interface Relationship { + id: string + relatedCharacterId: string + relatedCharacterName: string + relationshipType: string + description: string | null +} + +export interface Character { + id: string + projectId: string + name: string + role: CharacterRole + age: string | null + pronouns: string | null + occupation: string | null + appearance: string | null + personality: string | null + backstory: string | null + want: string | null + need: string | null + internalConflict: string | null + externalConflict: string | null + arcSummary: string | null + voice: string | null + notes: string | null + relationships: Relationship[] + updatedAt: string +} + +export interface OutlineNode { + id: string + projectId: string + parentId: string | null + nodeType: OutlineNodeType + title: string + summary: string | null + sortOrder: number + chapterId: string | null + children: OutlineNode[] +} + +export interface Scene { + id: string + chapterId: string + sortOrder: number + title: string + summary: string | null + goal: string | null + conflict: string | null + outcome: string | null + povCharacterId: string | null + povCharacterName: string | null + location: string | null + prose: string | null + wordCount: number + status: DraftStatus + updatedAt: string +} + +export interface ChapterSummary { + id: string + projectId: string + number: number + title: string + summary: string | null + povCharacterId: string | null + povCharacterName: string | null + setting: string | null + status: DraftStatus + targetWordCount: number | null + sceneCount: number + wordCount: number +} + +export interface Chapter extends Omit { + notes: string | null + scenes: Scene[] + updatedAt: string +} + +export interface ToolCall { + name: string + input: string + result: string +} + +export interface AgentMessage { + id: string + role: 'User' | 'Assistant' + content: string + toolCalls: ToolCall[] + createdAt: string +} + +export interface ConversationSummary { + id: string + projectId: string + title: string + messageCount: number + updatedAt: string +} + +export interface Conversation extends ConversationSummary { + messages: AgentMessage[] +} + +export interface AgentTurn { + conversationId: string + message: AgentMessage +} diff --git a/src/NovelSoftware.Web/src/components/ui.tsx b/src/NovelSoftware.Web/src/components/ui.tsx new file mode 100644 index 0000000..dd5ced8 --- /dev/null +++ b/src/NovelSoftware.Web/src/components/ui.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' +import type { DraftStatus } from '../api/types' + +export function Spinner({ label = 'Loading' }: { label?: string }) { + return ( +
+ + {label}… +
+ ) +} + +export function ErrorNote({ error }: { error: unknown }) { + const message = error instanceof Error ? error.message : String(error) + return ( +
+ {message} +
+ ) +} + +export function EmptyState({ title, hint }: { title: string; hint?: ReactNode }) { + return ( +
+

{title}

+ {hint &&

{hint}

} +
+ ) +} + +const statusTone: Record = { + Planned: '#8a8178', + Outlined: '#5b7fa8', + Drafted: '#a8813f', + Revised: '#63914f', + Final: '#4a8f7b', +} + +export function StatusBadge({ status }: { status: DraftStatus }) { + const tone = statusTone[status] + return ( + + {status} + + ) +} + +/** + * A field that saves when it loses focus. Writing tools live or die on not making the + * user hunt for a save button, so every editable field here commits on blur. + */ +export function AutoField({ + label, + value, + onCommit, + multiline, + rows = 3, + placeholder, + serif, +}: { + label?: string + value: string | null | undefined + onCommit: (next: string) => void + multiline?: boolean + rows?: number + placeholder?: string + serif?: boolean +}) { + const [draft, setDraft] = useState(value ?? '') + const committed = useRef(value ?? '') + + // Adopt changes that arrive from elsewhere (the agent, another tab) unless the user + // is mid-edit, which would yank text out from under them. + useEffect(() => { + const incoming = value ?? '' + if (incoming !== committed.current) { + committed.current = incoming + setDraft(incoming) + } + }, [value]) + + const commit = () => { + if (draft !== committed.current) { + committed.current = draft + onCommit(draft) + } + } + + const className = `input ${serif ? 'prose-serif' : ''}` + + return ( +