Add novel-writing app: .NET 10 API, React front end, agent and MCP server
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
3c85bab4a4
commit
0d7b7a6f30
@@ -427,3 +427,11 @@ FodyWeavers.xsd
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
## Novel Software
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
mcp-server/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"novel-software": {
|
||||
"command": "./mcp-server/NovelSoftware.Mcp",
|
||||
"env": {
|
||||
"NOVELSOFTWARE_API_URL": "http://localhost:5080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/NovelSoftware.Api/NovelSoftware.Api.csproj" />
|
||||
<Project Path="src/NovelSoftware.Application/NovelSoftware.Application.csproj" />
|
||||
<Project Path="src/NovelSoftware.Domain/NovelSoftware.Domain.csproj" />
|
||||
<Project Path="src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj" />
|
||||
<Project Path="src/NovelSoftware.Mcp/NovelSoftware.Mcp.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/NovelSoftware.Tests/NovelSoftware.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -1 +1,174 @@
|
||||
# novel-software
|
||||
# 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NovelSoftware.Infrastructure\NovelSoftware.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<string[]>()
|
||||
?? ["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<NovelDbContext>().Database.MigrateAsync();
|
||||
}
|
||||
|
||||
app.UseExceptionHandler(handler => handler.Run(async context =>
|
||||
{
|
||||
var exception = context.Features.Get<IExceptionHandlerFeature>()?.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();
|
||||
|
||||
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
|
||||
public partial class Program;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"NovelSoftware": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NovelSoftware.Application.Agent;
|
||||
|
||||
/// <summary>A tool the model may call, described in the shape the Messages API expects.</summary>
|
||||
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema);
|
||||
|
||||
/// <summary>One content block in a model turn.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>A full turn in the conversation sent to or received from the model.</summary>
|
||||
public record AgentChatMessage(string Role, IReadOnlyList<AgentContentBlock> Content)
|
||||
{
|
||||
public static AgentChatMessage User(params AgentContentBlock[] content) => new("user", content);
|
||||
public static AgentChatMessage Assistant(IReadOnlyList<AgentContentBlock> content) => new("assistant", content);
|
||||
}
|
||||
|
||||
public record AgentModelResponse(IReadOnlyList<AgentContentBlock> Content, string? StopReason);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IAgentModelClient
|
||||
{
|
||||
Task<AgentModelResponse> CompleteAsync(
|
||||
string systemPrompt,
|
||||
IReadOnlyList<AgentChatMessage> messages,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Configuration for the embedded writing agent.</summary>
|
||||
public class AgentOptions
|
||||
{
|
||||
public const string SectionName = "Agent";
|
||||
|
||||
/// <summary>Anthropic model id. Defaults to the current Opus.</summary>
|
||||
public string Model { get; set; } = "claude-opus-5";
|
||||
|
||||
public int MaxTokens { get; set; } = 16000;
|
||||
|
||||
/// <summary>Thinking depth: low | medium | high | xhigh | max.</summary>
|
||||
public string Effort { get; set; } = "high";
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on model round-trips per user turn. Each tool call costs one; without a
|
||||
/// cap a confused model could loop indefinitely.
|
||||
/// </summary>
|
||||
public int MaxIterations { get; set; } = 12;
|
||||
|
||||
/// <summary>Falls back to the ANTHROPIC_API_KEY environment variable when unset.</summary>
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace NovelSoftware.Application.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> 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<JsonElement>(schema.ToJsonString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lenient readers for tool input, which arrives as untyped JSON.</summary>
|
||||
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<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
|
||||
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class NovelAgentService(
|
||||
INovelDbContext db,
|
||||
IAgentModelClient model,
|
||||
NovelAgentToolset toolset,
|
||||
IOptions<AgentOptions> options,
|
||||
ILogger<NovelAgentService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
public async Task<IReadOnlyList<ConversationSummaryDto>> 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<ConversationDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<AgentTurnDto> 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<ToolCallDto>();
|
||||
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<AgentTextBlock>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(block.Text))
|
||||
{
|
||||
text.AppendLine(block.Text.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var requestedTools = response.Content.OfType<AgentToolUseBlock>().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<AgentContentBlock>();
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<AgentMessage> 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<AgentConversation> 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<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) =>
|
||||
await db.Conversations
|
||||
.Include(c => c.Messages)
|
||||
.FirstOrDefaultAsync(c => c.Id == conversationId, ct)
|
||||
?? throw new NotFoundException(nameof(AgentConversation), conversationId);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static List<AgentChatMessage> 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<string> 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<List<ToolCallDto>>(message.ToolCallsJson, JsonOptions) ?? [],
|
||||
message.CreatedAt);
|
||||
|
||||
/// <summary>Derives a conversation title from its opening message.</summary>
|
||||
private static string Summarise(string message)
|
||||
{
|
||||
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
||||
return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "...");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
using System.Text.Json;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
using NovelSoftware.Domain;
|
||||
|
||||
namespace NovelSoftware.Application.Agent;
|
||||
|
||||
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
|
||||
public sealed record AgentTool(
|
||||
string Name,
|
||||
string Description,
|
||||
JsonElement InputSchema,
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, AgentTool>? _byName;
|
||||
|
||||
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
||||
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
||||
|
||||
private IEnumerable<AgentTool> 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<CharacterRole>(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<CharacterRole>(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<OutlineNodeType>())
|
||||
.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<OutlineNodeType>(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<OutlineNodeType>())
|
||||
.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<OutlineNodeType>(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<DraftStatus>())
|
||||
.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<DraftStatus>(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<DraftStatus>())
|
||||
.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<DraftStatus>(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<DraftStatus>(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<DraftStatus>(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<CharacterRole>())
|
||||
.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<DraftStatus>());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NovelSoftware.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class AgentNotConfiguredException(string message) : Exception(message);
|
||||
@@ -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<AgentMessageDto> Messages,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record AgentMessageDto(
|
||||
Guid Id,
|
||||
AgentRole Role,
|
||||
string Content,
|
||||
IReadOnlyList<ToolCallDto> ToolCalls,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>A record of one tool the agent invoked, surfaced so the writer can audit changes.</summary>
|
||||
public record ToolCallDto(string Name, string Input, string Result);
|
||||
|
||||
public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
|
||||
|
||||
public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message);
|
||||
@@ -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<SceneDto> 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));
|
||||
}
|
||||
@@ -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<RelationshipDto> 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);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using NovelSoftware.Domain;
|
||||
|
||||
namespace NovelSoftware.Application.Dtos;
|
||||
|
||||
/// <summary>An outline node with its subtree inlined — the shape the outline view renders.</summary>
|
||||
public record OutlineNodeDto(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid? ParentId,
|
||||
OutlineNodeType NodeType,
|
||||
string Title,
|
||||
string? Summary,
|
||||
int SortOrder,
|
||||
Guid? ChapterId,
|
||||
IReadOnlyList<OutlineNodeDto> 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);
|
||||
|
||||
/// <summary>Moves a node to a new parent and/or position. A null <see cref="ParentId"/> means root level.</summary>
|
||||
public record MoveOutlineNodeRequest(Guid? ParentId, int SortOrder);
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update: every field is optional and null means "leave alone".
|
||||
/// Clearing a field is done by sending an empty string.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Whitespace-delimited word count. Good enough for progress tracking, and it costs
|
||||
/// nothing to recompute on every save.
|
||||
/// </summary>
|
||||
public static int CountWords(string? prose) =>
|
||||
string.IsNullOrWhiteSpace(prose)
|
||||
? 0
|
||||
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Application;
|
||||
|
||||
/// <summary>
|
||||
/// The persistence surface the application services depend on. Infrastructure supplies
|
||||
/// the EF Core implementation; tests can point it at an in-memory SQLite connection.
|
||||
/// </summary>
|
||||
public interface INovelDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
DbSet<Character> Characters { get; }
|
||||
DbSet<CharacterRelationship> CharacterRelationships { get; }
|
||||
DbSet<OutlineNode> OutlineNodes { get; }
|
||||
DbSet<Chapter> Chapters { get; }
|
||||
DbSet<Scene> Scenes { get; }
|
||||
DbSet<AgentConversation> Conversations { get; }
|
||||
DbSet<AgentMessage> AgentMessages { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace NovelSoftware.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class NotFoundException(string entity, Guid id)
|
||||
: Exception($"{entity} '{id}' was not found.")
|
||||
{
|
||||
public string Entity { get; } = entity;
|
||||
public Guid Id { get; } = id;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NovelSoftware.Domain\NovelSoftware.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<IReadOnlyList<ChapterSummaryDto>> 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<ChapterDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<ChapterDto> 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<ChapterDto> 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<int> 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<Chapter> 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);
|
||||
}
|
||||
@@ -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<IReadOnlyList<CharacterDto>> 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<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<CharacterDto> 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<CharacterDto> 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<CharacterDto> 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<Character> Query() =>
|
||||
db.Characters
|
||||
.Include(c => c.Relationships)
|
||||
.ThenInclude(r => r.RelatedCharacter);
|
||||
|
||||
private async Task<Character> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Application.Services;
|
||||
|
||||
public class OutlineService(INovelDbContext db)
|
||||
{
|
||||
/// <summary>Returns the project's outline as a tree of root nodes with children inlined.</summary>
|
||||
public async Task<IReadOnlyList<OutlineNodeDto>> 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<OutlineNodeDto> 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<OutlineNodeDto> 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<OutlineNodeDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reparents a node. Refuses to move a node under one of its own descendants, which
|
||||
/// would detach the subtree from the tree entirely.
|
||||
/// </summary>
|
||||
public async Task<OutlineNodeDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a node and its entire subtree.</summary>
|
||||
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<int> 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<OutlineNode> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await db.OutlineNodes.FirstOrDefaultAsync(n => n.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(OutlineNode), id);
|
||||
|
||||
private static IReadOnlyList<OutlineNodeDto> BuildTree(List<OutlineNode> 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<OutlineNode> 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<Guid> DescendantIds(List<OutlineNode> all, Guid rootId)
|
||||
{
|
||||
var frontier = new Queue<Guid>([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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<ProjectSummaryDto>> 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<ProjectDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<ProjectDto> 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<ProjectDto> 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<Project> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Project), id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch semantics shared by every update endpoint: a null value leaves the field
|
||||
/// untouched, an empty string clears it.
|
||||
/// </summary>
|
||||
internal static class Patch
|
||||
{
|
||||
public static string? Apply(string? current, string? incoming) => incoming switch
|
||||
{
|
||||
null => current,
|
||||
"" => null,
|
||||
_ => incoming
|
||||
};
|
||||
}
|
||||
@@ -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<IReadOnlyList<SceneDto>> 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<SceneDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<SceneDto> 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<SceneDto> 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<int> 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<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
|
||||
|
||||
private async Task<Scene> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Scene), id);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace NovelSoftware.Domain.Entities;
|
||||
|
||||
/// <summary>A chat thread between the writer and the embedded agent, scoped to one project.</summary>
|
||||
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<AgentMessage> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int Sequence { get; set; }
|
||||
|
||||
/// <summary>The visible text of the turn.</summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON array of <c>{ name, input, result }</c> objects describing tool calls made
|
||||
/// during this turn. Null on user turns and on assistant turns that used no tools.
|
||||
/// </summary>
|
||||
public string? ToolCallsJson { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace NovelSoftware.Domain.Entities;
|
||||
|
||||
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
/// <summary>Position in the manuscript, 1-based.</summary>
|
||||
public int Number { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>Whose head we are in for this chapter.</summary>
|
||||
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<Scene> Scenes { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace NovelSoftware.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A character dossier. Every field beyond <see cref="Name"/> is optional so a writer can
|
||||
/// start with a name and fill the sheet in as the character comes into focus.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>What the character consciously wants.</summary>
|
||||
public string? Want { get; set; }
|
||||
|
||||
/// <summary>What the character actually needs — usually at odds with <see cref="Want"/>.</summary>
|
||||
public string? Need { get; set; }
|
||||
|
||||
public string? InternalConflict { get; set; }
|
||||
public string? ExternalConflict { get; set; }
|
||||
|
||||
/// <summary>How the character changes over the course of the book.</summary>
|
||||
public string? ArcSummary { get; set; }
|
||||
|
||||
/// <summary>Speech patterns, verbal tics, register — anything that makes dialogue sound like them.</summary>
|
||||
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<CharacterRelationship> Relationships { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A directed relationship from one character to another.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
|
||||
public string RelationshipType { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace NovelSoftware.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<OutlineNode> Children { get; set; } = [];
|
||||
|
||||
public OutlineNodeType NodeType { get; set; } = OutlineNodeType.Beat;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>Position among siblings. Gaps are allowed; ordering is by this value then title.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>Optional link to the chapter that realises this outline node.</summary>
|
||||
public Guid? ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace NovelSoftware.Domain.Entities;
|
||||
|
||||
/// <summary>A single novel and everything that belongs to it.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>One-sentence pitch.</summary>
|
||||
public string? Logline { get; set; }
|
||||
|
||||
/// <summary>Paragraph-length summary of the whole book.</summary>
|
||||
public string? Synopsis { get; set; }
|
||||
|
||||
/// <summary>Free-form notes on theme, tone, comparable titles, etc.</summary>
|
||||
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<Character> Characters { get; set; } = [];
|
||||
public List<Chapter> Chapters { get; set; } = [];
|
||||
public List<OutlineNode> OutlineNodes { get; set; } = [];
|
||||
public List<AgentConversation> Conversations { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace NovelSoftware.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent
|
||||
/// works with when turning an outline into prose.
|
||||
/// </summary>
|
||||
public class Scene
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>Position within the chapter, 1-based.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>What the POV character is trying to achieve.</summary>
|
||||
public string? Goal { get; set; }
|
||||
|
||||
/// <summary>What stands in the way.</summary>
|
||||
public string? Conflict { get; set; }
|
||||
|
||||
/// <summary>How it lands — and what it costs.</summary>
|
||||
public string? Outcome { get; set; }
|
||||
|
||||
public Guid? PovCharacterId { get; set; }
|
||||
public Character? PovCharacter { get; set; }
|
||||
|
||||
public string? Location { get; set; }
|
||||
|
||||
/// <summary>The drafted prose, if any.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace NovelSoftware.Domain;
|
||||
|
||||
/// <summary>The role a character plays in the story.</summary>
|
||||
public enum CharacterRole
|
||||
{
|
||||
Protagonist,
|
||||
Antagonist,
|
||||
Deuteragonist,
|
||||
Supporting,
|
||||
Minor,
|
||||
Mentor,
|
||||
LoveInterest,
|
||||
Foil
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum OutlineNodeType
|
||||
{
|
||||
Part,
|
||||
Act,
|
||||
Sequence,
|
||||
Chapter,
|
||||
Beat,
|
||||
Note
|
||||
}
|
||||
|
||||
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
|
||||
public enum DraftStatus
|
||||
{
|
||||
Planned,
|
||||
Outlined,
|
||||
Drafted,
|
||||
Revised,
|
||||
Final
|
||||
}
|
||||
|
||||
/// <summary>Who produced a message in an agent conversation.</summary>
|
||||
public enum AgentRole
|
||||
{
|
||||
User,
|
||||
Assistant
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="NovelAgentService"/>.
|
||||
/// </summary>
|
||||
public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentModelClient
|
||||
{
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
private AnthropicClient? _client;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AgentModelResponse> CompleteAsync(
|
||||
string systemPrompt,
|
||||
IReadOnlyList<AgentChatMessage> messages,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var parameters = new MessageCreateParams
|
||||
{
|
||||
Model = _options.Model,
|
||||
MaxTokens = _options.MaxTokens,
|
||||
System = new List<TextBlockParam>
|
||||
{
|
||||
// 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<AgentContentBlock>()],
|
||||
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<string, JsonElement>();
|
||||
if (definition.InputSchema.TryGetProperty("properties", out var props)
|
||||
&& props.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in props.EnumerateObject())
|
||||
{
|
||||
properties[property.Name] = property.Value;
|
||||
}
|
||||
}
|
||||
|
||||
List<string> 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<ContentBlockParam>([.. 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<string, JsonElement> ToInputDictionary(JsonElement input)
|
||||
{
|
||||
var dictionary = new Dictionary<string, JsonElement>();
|
||||
|
||||
if (input.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in input.EnumerateObject())
|
||||
{
|
||||
dictionary[property.Name] = property.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
}
|
||||
@@ -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<NovelDbContext>(options => options.UseSqlite(connectionString));
|
||||
services.AddScoped<INovelDbContext>(sp => sp.GetRequiredService<NovelDbContext>());
|
||||
|
||||
services.AddScoped<ProjectService>();
|
||||
services.AddScoped<CharacterService>();
|
||||
services.AddScoped<OutlineService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<SceneService>();
|
||||
services.AddScoped<NovelAgentToolset>();
|
||||
services.AddScoped<NovelAgentService>();
|
||||
|
||||
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
||||
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NovelSoftware.Application\NovelSoftware.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Anthropic" Version="12.39.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Generated
+532
@@ -0,0 +1,532 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NodeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Conflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NovelSoftware.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Projects",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Author = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Genre = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Logline = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Synopsis = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
TargetWordCount = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Projects", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Characters",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Age = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Pronouns = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Occupation = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Appearance = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Personality = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Backstory = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Want = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Need = table.Column<string>(type: "TEXT", nullable: true),
|
||||
InternalConflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ExternalConflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ArcSummary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Voice = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(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<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(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<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Number = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Setting = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
TargetWordCount = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(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<Guid>(type: "TEXT", nullable: false),
|
||||
CharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
RelatedCharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
RelationshipType = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Description = table.Column<string>(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<Guid>(type: "TEXT", nullable: false),
|
||||
ConversationId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Role = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
|
||||
Sequence = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Content = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ToolCallsJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(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<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ParentId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
NodeType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(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<Guid>(type: "TEXT", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Goal = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Conflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Outcome = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Location = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Prose = table.Column<string>(type: "TEXT", nullable: true),
|
||||
WordCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
// <auto-generated />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NodeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Conflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NovelSoftware.Application;
|
||||
using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Stores a <see cref="DateTimeOffset"/> 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.
|
||||
/// </summary>
|
||||
internal sealed class UtcTicksConverter()
|
||||
: ValueConverter<DateTimeOffset, long>(
|
||||
value => value.UtcTicks,
|
||||
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
||||
|
||||
public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
: DbContext(options), INovelDbContext
|
||||
{
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
|
||||
public DbSet<OutlineNode> OutlineNodes => Set<OutlineNode>();
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
public DbSet<Scene> Scenes => Set<Scene>();
|
||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
||||
|
||||
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
|
||||
base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
protected override void ConfigureConventions(ModelConfigurationBuilder builder) =>
|
||||
builder.Properties<DateTimeOffset>().HaveConversion<UtcTicksConverter>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<Project>(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<Character>(entity =>
|
||||
{
|
||||
entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||
entity.Property(c => c.Role).HasConversion<string>().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<CharacterRelationship>(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<OutlineNode>(entity =>
|
||||
{
|
||||
entity.Property(n => n.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(n => n.NodeType).HasConversion<string>().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<Chapter>(entity =>
|
||||
{
|
||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(c => c.Status).HasConversion<string>().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<Scene>(entity =>
|
||||
{
|
||||
entity.Property(s => s.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(s => s.Status).HasConversion<string>().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<AgentConversation>(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<AgentMessage>(entity =>
|
||||
{
|
||||
entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16);
|
||||
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace NovelSoftware.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class NovelApiClient(HttpClient http)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public Task<CallToolResult> GetAsync(string path, CancellationToken ct = default) =>
|
||||
SendAsync(new HttpRequestMessage(HttpMethod.Get, path), ct);
|
||||
|
||||
public Task<CallToolResult> PostAsync(string path, object body, CancellationToken ct = default) =>
|
||||
SendAsync(new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = JsonContent.Create(body, options: Options)
|
||||
}, ct);
|
||||
|
||||
public Task<CallToolResult> PatchAsync(string path, object body, CancellationToken ct = default) =>
|
||||
SendAsync(new HttpRequestMessage(HttpMethod.Patch, path)
|
||||
{
|
||||
Content = JsonContent.Create(body, options: Options)
|
||||
}, ct);
|
||||
|
||||
public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) =>
|
||||
SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<CallToolResult> 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 };
|
||||
|
||||
/// <summary>Reformats the API's compact JSON so tool output reads well in a transcript.</summary>
|
||||
private static string Prettify(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(JsonSerializer.Deserialize<JsonElement>(json), Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryReadProblemDetail(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
return problem.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<NovelApiClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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);
|
||||
}
|
||||
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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);
|
||||
}
|
||||
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> DeleteOutlineNode(
|
||||
NovelApiClient api,
|
||||
[Description("The node's id.")] Guid nodeId,
|
||||
CancellationToken ct) =>
|
||||
api.DeleteAsync($"/api/outline/{nodeId}", ct);
|
||||
}
|
||||
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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);
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Novel Software</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2053
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||
<Route index element={<OverviewPage />} />
|
||||
<Route path="characters" element={<CharactersPage />} />
|
||||
<Route path="outline" element={<OutlinePage />} />
|
||||
<Route path="chapters" element={<ChaptersPage />} />
|
||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||
<Route path="agent" element={<AgentPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<ProjectsPage />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -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<ProjectSummary[]>('/api/projects') })
|
||||
|
||||
export const useProject = (id: string) =>
|
||||
useQuery({ queryKey: keys.project(id), queryFn: () => api.get<Project>(`/api/projects/${id}`) })
|
||||
|
||||
export function useCreateProject() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
|
||||
api.post<Project>('/api/projects', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateProject(id: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Project>) => api.patch<Project>(`/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<Character[]>(`/api/projects/${projectId}/characters`),
|
||||
})
|
||||
|
||||
export function useCreateCharacter(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Character> & { name: string }) =>
|
||||
api.post<Character>(`/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<Character> & { id: string }) =>
|
||||
api.patch<Character>(`/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<OutlineNode[]>(`/api/projects/${projectId}/outline`),
|
||||
})
|
||||
|
||||
export function useCreateOutlineNode(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<OutlineNode> & { title: string }) =>
|
||||
api.post<OutlineNode>(`/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<OutlineNode> & { id: string }) =>
|
||||
api.patch<OutlineNode>(`/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<ChapterSummary[]>(`/api/projects/${projectId}/chapters`),
|
||||
})
|
||||
|
||||
export const useChapter = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: keys.chapter(id ?? ''),
|
||||
queryFn: () => api.get<Chapter>(`/api/chapters/${id}`),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export function useCreateChapter(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Chapter> & { title: string }) =>
|
||||
api.post<Chapter>(`/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<Chapter> & { id: string }) =>
|
||||
api.patch<Chapter>(`/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<Scene> & { title: string }) =>
|
||||
api.post<Scene>(`/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<Scene> & { id: string }) =>
|
||||
api.patch<Scene>(`/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<ConversationSummary[]>(`/api/projects/${projectId}/agent/conversations`),
|
||||
})
|
||||
|
||||
export const useConversation = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: keys.conversation(id ?? ''),
|
||||
queryFn: () => api.get<Conversation>(`/api/conversations/${id}`),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export function useSendAgentMessage(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { message: string; conversationId?: string }) =>
|
||||
api.post<AgentTurn>(`/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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<ChapterSummary, 'sceneCount' | 'wordCount'> {
|
||||
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
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2 py-8 text-sm muted">
|
||||
<span
|
||||
className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
{label}…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorNote({ error }: { error: unknown }) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md px-3 py-2 text-sm"
|
||||
style={{ background: 'var(--accent-soft)', color: 'var(--accent)' }}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ title, hint }: { title: string; hint?: ReactNode }) {
|
||||
return (
|
||||
<div className="card px-6 py-10 text-center">
|
||||
<p className="font-medium">{title}</p>
|
||||
{hint && <p className="mx-auto mt-1 max-w-md text-sm muted">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusTone: Record<DraftStatus, string> = {
|
||||
Planned: '#8a8178',
|
||||
Outlined: '#5b7fa8',
|
||||
Drafted: '#a8813f',
|
||||
Revised: '#63914f',
|
||||
Final: '#4a8f7b',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: DraftStatus }) {
|
||||
const tone = statusTone[status]
|
||||
return (
|
||||
<span
|
||||
className="inline-block rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold tracking-wide uppercase"
|
||||
style={{ color: tone, background: `color-mix(in srgb, ${tone} 14%, transparent)` }}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<label className="block">
|
||||
{label && <span className="label">{label}</span>}
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={className}
|
||||
rows={rows}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={className}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label?: string
|
||||
value: T
|
||||
options: readonly T[]
|
||||
onChange: (next: T) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
{label && <span className="label">{label}</span>}
|
||||
<select className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-6"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="card mt-12 w-full max-w-lg p-5 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button className="btn px-2 py-1" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
|
||||
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/*
|
||||
* Warm paper light theme, cool ink dark theme. Colours are declared as variables so
|
||||
* every surface, border and accent moves together when the scheme flips.
|
||||
*/
|
||||
:root {
|
||||
--paper: #faf7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-sunken: #f2ede2;
|
||||
--ink: #241f1a;
|
||||
--ink-muted: #6b6157;
|
||||
--line: #e0d8c8;
|
||||
--accent: #9a4a2f;
|
||||
--accent-soft: #f6e9e2;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--paper: #16151a;
|
||||
--surface: #1e1d24;
|
||||
--surface-sunken: #131217;
|
||||
--ink: #ece7de;
|
||||
--ink-muted: #9a9288;
|
||||
--line: #322f39;
|
||||
--accent: #e08b62;
|
||||
--accent-soft: #2c2229;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--paper: #16151a;
|
||||
--surface: #1e1d24;
|
||||
--surface-sunken: #131217;
|
||||
--ink: #ece7de;
|
||||
--ink-muted: #9a9288;
|
||||
--line: #322f39;
|
||||
--accent: #e08b62;
|
||||
--accent-soft: #2c2229;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--paper: #faf7f0;
|
||||
--surface: #ffffff;
|
||||
--surface-sunken: #f2ede2;
|
||||
--ink: #241f1a;
|
||||
--ink-muted: #6b6157;
|
||||
--line: #e0d8c8;
|
||||
--accent: #9a4a2f;
|
||||
--accent-soft: #f6e9e2;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mb-1 block text-xs font-semibold tracking-wide uppercase;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.prose-serif {
|
||||
font-family: var(--font-sans);
|
||||
@apply text-[1.0625rem] leading-relaxed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 15_000, refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks'
|
||||
import type { AgentMessage } from '../api/types'
|
||||
import { ErrorNote, Spinner } from '../components/ui'
|
||||
|
||||
const starters = [
|
||||
'Read the brief and tell me what the outline is missing.',
|
||||
"Draft a three-act skeleton from the logline, then stop so I can react.",
|
||||
'Look at my protagonist: is the want genuinely in tension with the need?',
|
||||
]
|
||||
|
||||
export default function AgentPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: conversations } = useConversations(projectId)
|
||||
const [conversationId, setConversationId] = useState<string | undefined>()
|
||||
const { data: conversation } = useConversation(conversationId)
|
||||
const send = useSendAgentMessage(projectId)
|
||||
const [draft, setDraft] = useState('')
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [conversation?.messages.length, send.isPending])
|
||||
|
||||
const submit = (message: string) => {
|
||||
const trimmed = message.trim()
|
||||
if (!trimmed || send.isPending) return
|
||||
setDraft('')
|
||||
send.mutate(
|
||||
{ message: trimmed, conversationId },
|
||||
{ onSuccess: (turn) => setConversationId(turn.conversationId) },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
|
||||
<aside className="grid content-start gap-2">
|
||||
<button
|
||||
className="btn btn-primary w-full justify-center"
|
||||
onClick={() => setConversationId(undefined)}
|
||||
>
|
||||
New conversation
|
||||
</button>
|
||||
{conversations?.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setConversationId(item.id)}
|
||||
className="card px-3 py-2 text-left text-sm transition hover:shadow-sm"
|
||||
style={
|
||||
item.id === conversationId
|
||||
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="truncate">{item.title}</div>
|
||||
<div className="text-xs muted">
|
||||
{item.messageCount} message{item.messageCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-[70vh] flex-col">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
|
||||
{!conversation && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold">Your writing partner</h2>
|
||||
<p className="mt-1 text-sm muted">
|
||||
It can read and edit the brief, the outline, character dossiers, chapters and
|
||||
scenes — the same data you see in the other tabs.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{starters.map((starter) => (
|
||||
<button
|
||||
key={starter}
|
||||
className="btn justify-start text-left"
|
||||
onClick={() => submit(starter)}
|
||||
>
|
||||
{starter}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conversation?.messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{send.isPending && <Spinner label="Thinking" />}
|
||||
{send.error && <ErrorNote error={send.error} />}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex gap-2 pt-3"
|
||||
style={{ borderTop: '1px solid var(--line)' }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit(draft)
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
className="input flex-1 resize-none"
|
||||
rows={3}
|
||||
value={draft}
|
||||
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
submit(draft)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-1 text-xs muted">⌘/Ctrl + Enter to send.</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: AgentMessage }) {
|
||||
const isUser = message.role === 'User'
|
||||
|
||||
return (
|
||||
<div className={isUser ? 'flex justify-end' : ''}>
|
||||
<div
|
||||
className={`card max-w-[46rem] px-4 py-3 ${isUser ? '' : 'w-full'}`}
|
||||
style={isUser ? { background: 'var(--accent-soft)', borderColor: 'transparent' } : undefined}
|
||||
>
|
||||
<div className="prose-serif whitespace-pre-wrap">{message.content}</div>
|
||||
|
||||
{message.toolCalls.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs muted">
|
||||
{message.toolCalls.length} change
|
||||
{message.toolCalls.length === 1 ? '' : 's'} made
|
||||
</summary>
|
||||
<ul className="mt-2 grid gap-2">
|
||||
{message.toolCalls.map((call, index) => (
|
||||
<li key={index} className="rounded-md p-2 text-xs" style={{ background: 'var(--surface-sunken)' }}>
|
||||
<div className="font-mono font-semibold">{call.name}</div>
|
||||
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words opacity-70">
|
||||
{call.input}
|
||||
</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
useChapter,
|
||||
useCharacters,
|
||||
useCreateScene,
|
||||
useDeleteChapter,
|
||||
useDeleteScene,
|
||||
useUpdateChapter,
|
||||
useUpdateScene,
|
||||
} from '../api/hooks'
|
||||
import { draftStatuses, type Chapter, type Scene } from '../api/types'
|
||||
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
||||
|
||||
export default function ChapterPage() {
|
||||
const { projectId = '', chapterId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: chapter, isPending, error } = useChapter(chapterId)
|
||||
const { data: characters } = useCharacters(projectId)
|
||||
const update = useUpdateChapter(projectId)
|
||||
const remove = useDeleteChapter(projectId)
|
||||
const createScene = useCreateScene(chapterId ?? '')
|
||||
|
||||
if (isPending) return <Spinner label="Loading chapter" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
if (!chapter) return null
|
||||
|
||||
const patch = (body: Partial<Chapter>) => update.mutate({ id: chapter.id, ...body })
|
||||
const povOptions = ['—', ...(characters?.map((c) => c.name) ?? [])]
|
||||
const povValue = chapter.povCharacterName ?? '—'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Link to={`/projects/${projectId}/chapters`} className="text-sm muted hover:underline">
|
||||
← All chapters
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="card mb-6 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
|
||||
<label className="block">
|
||||
<span className="label">No.</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={chapter.number}
|
||||
onBlur={(e) => {
|
||||
const number = Number(e.target.value)
|
||||
if (number > 0 && number !== chapter.number) patch({ number })
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<AutoField
|
||||
label="Title"
|
||||
value={chapter.title}
|
||||
onCommit={(title) => title.trim() && patch({ title })}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
value={chapter.status}
|
||||
options={draftStatuses}
|
||||
onChange={(status) => patch({ status })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<AutoField
|
||||
label="Summary"
|
||||
value={chapter.summary}
|
||||
multiline
|
||||
rows={3}
|
||||
serif
|
||||
onCommit={(summary) => patch({ summary })}
|
||||
/>
|
||||
<div className="grid content-start gap-4">
|
||||
<label className="block">
|
||||
<span className="label">POV character</span>
|
||||
<select
|
||||
className="input"
|
||||
value={povValue}
|
||||
onChange={(e) => {
|
||||
const match = characters?.find((c) => c.name === e.target.value)
|
||||
patch({ povCharacterId: match?.id ?? null })
|
||||
}}
|
||||
>
|
||||
{povOptions.map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<AutoField
|
||||
label="Setting"
|
||||
value={chapter.setting}
|
||||
onCommit={(setting) => patch({ setting })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-end justify-between gap-4">
|
||||
<div className="text-sm muted">
|
||||
{chapter.scenes.length} scenes ·{' '}
|
||||
{chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
|
||||
</div>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete chapter “${chapter.title}” and its scenes?`)) {
|
||||
remove.mutate(chapter.id, {
|
||||
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete chapter
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Scenes</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => createScene.mutate({ title: 'New scene' })}
|
||||
disabled={createScene.isPending}
|
||||
>
|
||||
Add scene
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="grid gap-3">
|
||||
{chapter.scenes.map((scene) => (
|
||||
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SceneCard({ chapterId, scene }: { chapterId: string; scene: Scene }) {
|
||||
const [showProse, setShowProse] = useState(Boolean(scene.prose))
|
||||
const update = useUpdateScene(chapterId)
|
||||
const remove = useDeleteScene(chapterId)
|
||||
const patch = (body: Partial<Scene>) => update.mutate({ id: scene.id, ...body })
|
||||
|
||||
return (
|
||||
<li className="card p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_9rem]">
|
||||
<AutoField
|
||||
value={scene.title}
|
||||
onCommit={(title) => title.trim() && patch({ title })}
|
||||
/>
|
||||
<Select value={scene.status} options={draftStatuses} onChange={(status) => patch({ status })} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<AutoField
|
||||
label="Goal"
|
||||
value={scene.goal}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="What they want here."
|
||||
onCommit={(goal) => patch({ goal })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Conflict"
|
||||
value={scene.conflict}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="What gets in the way."
|
||||
onCommit={(conflict) => patch({ conflict })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Outcome"
|
||||
value={scene.outcome}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="How it lands, and what it costs."
|
||||
onCommit={(outcome) => patch({ outcome })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs muted">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={scene.status} />
|
||||
<span>{scene.wordCount.toLocaleString()} words</span>
|
||||
{scene.povCharacterName && <span>POV: {scene.povCharacterName}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn px-2 py-1 text-xs" onClick={() => setShowProse((v) => !v)}>
|
||||
{showProse ? 'Hide prose' : 'Write prose'}
|
||||
</button>
|
||||
<button
|
||||
className="btn px-2 py-1 text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => confirm(`Delete scene “${scene.title}”?`) && remove.mutate(scene.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showProse && (
|
||||
<div className="mt-3">
|
||||
<AutoField
|
||||
value={scene.prose}
|
||||
multiline
|
||||
rows={16}
|
||||
serif
|
||||
placeholder="The scene itself. Ask the agent to draft from the beats above if you would rather start from something."
|
||||
onCommit={(prose) => patch({ prose })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCreateChapter } from '../api/hooks'
|
||||
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||
|
||||
export default function ChaptersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: chapters, isPending, error } = useChapters(projectId)
|
||||
const create = useCreateChapter(projectId)
|
||||
|
||||
if (isPending) return <Spinner label="Loading chapters" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">Chapters</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => create.mutate({ title: 'Untitled chapter' })}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
Add chapter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{create.error && <ErrorNote error={create.error} />}
|
||||
|
||||
{chapters?.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No chapters yet"
|
||||
hint="Chapters hold scenes, and scenes hold the prose. Add one and start breaking it down."
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid gap-2">
|
||||
{chapters?.map((chapter) => (
|
||||
<li key={chapter.id}>
|
||||
<Link
|
||||
to={`/projects/${projectId}/chapters/${chapter.id}`}
|
||||
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
|
||||
>
|
||||
<span className="w-8 shrink-0 text-right text-sm font-semibold muted">
|
||||
{chapter.number}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{chapter.title}</div>
|
||||
{chapter.summary && <div className="truncate text-sm muted">{chapter.summary}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs muted">
|
||||
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
|
||||
<span>{chapter.sceneCount} scenes</span>
|
||||
<span>{chapter.wordCount.toLocaleString()} words</span>
|
||||
<StatusBadge status={chapter.status} />
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import {
|
||||
useCharacters,
|
||||
useCreateCharacter,
|
||||
useDeleteCharacter,
|
||||
useUpdateCharacter,
|
||||
} from '../api/hooks'
|
||||
import { characterRoles, type Character } from '../api/types'
|
||||
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||
|
||||
export default function CharactersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: characters, isPending, error } = useCharacters(projectId)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
if (isPending) return <Spinner label="Loading characters" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
|
||||
const selected = characters?.find((c) => c.id === selectedId) ?? characters?.[0]
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[16rem_1fr]">
|
||||
<aside className="grid content-start gap-2">
|
||||
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
|
||||
Add character
|
||||
</button>
|
||||
{characters?.map((character) => (
|
||||
<button
|
||||
key={character.id}
|
||||
onClick={() => setSelectedId(character.id)}
|
||||
className="card px-3 py-2 text-left transition hover:shadow-sm"
|
||||
style={
|
||||
character.id === selected?.id
|
||||
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="truncate font-medium">{character.name}</div>
|
||||
<div className="text-xs muted">{character.role}</div>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
title="No characters yet"
|
||||
hint="Add the protagonist first — most outline questions resolve once you know what they want."
|
||||
/>
|
||||
) : (
|
||||
<CharacterSheet key={selected.id} projectId={projectId} character={selected} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{adding && (
|
||||
<AddCharacterModal
|
||||
projectId={projectId}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CharacterSheet({ projectId, character }: { projectId: string; character: Character }) {
|
||||
const update = useUpdateCharacter(projectId)
|
||||
const remove = useDeleteCharacter(projectId)
|
||||
const patch = (body: Partial<Character>) => update.mutate({ id: character.id, ...body })
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div className="grid flex-1 gap-3 sm:grid-cols-[1fr_12rem]">
|
||||
<AutoField
|
||||
label="Name"
|
||||
value={character.name}
|
||||
onCommit={(name) => name.trim() && patch({ name })}
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
value={character.role}
|
||||
options={characterRoles}
|
||||
onChange={(role) => patch({ role })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn mt-6"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete ${character.name}?`)) remove.mutate(character.id)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} />
|
||||
<AutoField
|
||||
label="Pronouns"
|
||||
value={character.pronouns}
|
||||
onCommit={(pronouns) => patch({ pronouns })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Occupation"
|
||||
value={character.occupation}
|
||||
onCommit={(occupation) => patch({ occupation })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<AutoField
|
||||
label="Wants"
|
||||
value={character.want}
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="What they are consciously chasing."
|
||||
onCommit={(want) => patch({ want })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Needs"
|
||||
value={character.need}
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="What the story will make them face instead."
|
||||
onCommit={(need) => patch({ need })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Internal conflict"
|
||||
value={character.internalConflict}
|
||||
multiline
|
||||
onCommit={(internalConflict) => patch({ internalConflict })}
|
||||
/>
|
||||
<AutoField
|
||||
label="External conflict"
|
||||
value={character.externalConflict}
|
||||
multiline
|
||||
onCommit={(externalConflict) => patch({ externalConflict })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Arc"
|
||||
value={character.arcSummary}
|
||||
multiline
|
||||
onCommit={(arcSummary) => patch({ arcSummary })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Voice"
|
||||
value={character.voice}
|
||||
multiline
|
||||
placeholder="Register, rhythm, the words they reach for."
|
||||
onCommit={(voice) => patch({ voice })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Appearance"
|
||||
value={character.appearance}
|
||||
multiline
|
||||
onCommit={(appearance) => patch({ appearance })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Personality"
|
||||
value={character.personality}
|
||||
multiline
|
||||
onCommit={(personality) => patch({ personality })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<AutoField
|
||||
label="Backstory"
|
||||
value={character.backstory}
|
||||
multiline
|
||||
rows={5}
|
||||
serif
|
||||
onCommit={(backstory) => patch({ backstory })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Notes"
|
||||
value={character.notes}
|
||||
multiline
|
||||
onCommit={(notes) => patch({ notes })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{character.relationships.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="label">Relationships</h3>
|
||||
<ul className="grid gap-1 text-sm">
|
||||
{character.relationships.map((relationship) => (
|
||||
<li key={relationship.id}>
|
||||
<span className="font-medium">{relationship.relatedCharacterName}</span>
|
||||
<span className="muted"> — {relationship.relationshipType}</span>
|
||||
{relationship.description && <span className="muted">: {relationship.description}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{update.error && (
|
||||
<div className="mt-4">
|
||||
<ErrorNote error={update.error} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddCharacterModal({
|
||||
projectId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onCreated: (id: string) => void
|
||||
}) {
|
||||
const create = useCreateCharacter(projectId)
|
||||
const [name, setName] = useState('')
|
||||
const [role, setRole] = useState<Character['role']>('Supporting')
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
create.mutate(
|
||||
{ name: name.trim(), role },
|
||||
{
|
||||
onSuccess: (character) => {
|
||||
onCreated(character.id)
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Add character" onClose={onClose}>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
<label className="block">
|
||||
<span className="label">Name</span>
|
||||
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<Select label="Role" value={role} options={characterRoles} onChange={setRole} />
|
||||
{create.error && <ErrorNote error={create.error} />}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={!name.trim() || create.isPending}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import {
|
||||
useCreateOutlineNode,
|
||||
useDeleteOutlineNode,
|
||||
useOutline,
|
||||
useUpdateOutlineNode,
|
||||
} from '../api/hooks'
|
||||
import { outlineNodeTypes, type OutlineNode, type OutlineNodeType } from '../api/types'
|
||||
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
|
||||
|
||||
export default function OutlinePage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: outline, isPending, error } = useOutline(projectId)
|
||||
const create = useCreateOutlineNode(projectId)
|
||||
|
||||
if (isPending) return <Spinner label="Loading outline" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Outline</h2>
|
||||
<p className="text-sm muted">
|
||||
Nest freely — acts under parts, beats under sequences, or a flat list of beats.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => create.mutate({ title: 'New section', nodeType: 'Act' })}
|
||||
>
|
||||
Add top-level node
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{create.error && <ErrorNote error={create.error} />}
|
||||
|
||||
{outline?.length === 0 ? (
|
||||
<EmptyState
|
||||
title="The outline is empty"
|
||||
hint="Add three acts, then break each into the beats that carry it. The agent can draft a first pass if you ask it to."
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid gap-2">
|
||||
{outline?.map((node) => (
|
||||
<OutlineRow key={node.id} projectId={projectId} node={node} depth={0} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OutlineRow({
|
||||
projectId,
|
||||
node,
|
||||
depth,
|
||||
}: {
|
||||
projectId: string
|
||||
node: OutlineNode
|
||||
depth: number
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth < 2)
|
||||
const update = useUpdateOutlineNode(projectId)
|
||||
const remove = useDeleteOutlineNode(projectId)
|
||||
const create = useCreateOutlineNode(projectId)
|
||||
|
||||
return (
|
||||
<li style={{ marginLeft: depth * 20 }}>
|
||||
<div className="card px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
className="mt-1 w-4 shrink-0 text-xs muted"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{node.children.length > 0 ? (expanded ? '▾' : '▸') : '·'}
|
||||
</button>
|
||||
|
||||
<div className="grid flex-1 gap-2">
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_9rem]">
|
||||
<AutoField
|
||||
value={node.title}
|
||||
onCommit={(title) => title.trim() && update.mutate({ id: node.id, title })}
|
||||
/>
|
||||
<Select
|
||||
value={node.nodeType}
|
||||
options={outlineNodeTypes}
|
||||
onChange={(nodeType: OutlineNodeType) => update.mutate({ id: node.id, nodeType })}
|
||||
/>
|
||||
</div>
|
||||
<AutoField
|
||||
value={node.summary}
|
||||
multiline
|
||||
rows={2}
|
||||
serif
|
||||
placeholder="What happens here, and what it changes."
|
||||
onCommit={(summary) => update.mutate({ id: node.id, summary })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<button
|
||||
className="btn px-2 py-1 text-xs"
|
||||
title="Add a child node"
|
||||
onClick={() =>
|
||||
create.mutate({ title: 'New beat', nodeType: 'Beat', parentId: node.id })
|
||||
}
|
||||
>
|
||||
+ Child
|
||||
</button>
|
||||
<button
|
||||
className="btn px-2 py-1 text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
const warning =
|
||||
node.children.length > 0
|
||||
? `Delete “${node.title}” and its ${node.children.length} nested node(s)?`
|
||||
: `Delete “${node.title}”?`
|
||||
if (confirm(warning)) remove.mutate(node.id)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && node.children.length > 0 && (
|
||||
<ul className="mt-2 grid gap-2">
|
||||
{node.children.map((child) => (
|
||||
<OutlineRow key={child.id} projectId={projectId} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProject } from '../api/hooks'
|
||||
import { AutoField, ErrorNote, Spinner } from '../components/ui'
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: project, isPending } = useProject(projectId)
|
||||
const { data: characters } = useCharacters(projectId)
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const update = useUpdateProject(projectId)
|
||||
const remove = useDeleteProject()
|
||||
|
||||
if (isPending || !project) return <Spinner label="Loading brief" />
|
||||
|
||||
const drafted = chapters?.reduce((sum, c) => sum + c.wordCount, 0) ?? 0
|
||||
const target = project.targetWordCount ?? 0
|
||||
const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
|
||||
<section className="card p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold tracking-wide uppercase muted">The brief</h2>
|
||||
<div className="grid gap-4">
|
||||
<AutoField
|
||||
label="Title"
|
||||
value={project.title}
|
||||
onCommit={(title) => title.trim() && update.mutate({ title })}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AutoField
|
||||
label="Author"
|
||||
value={project.author}
|
||||
onCommit={(author) => update.mutate({ author })}
|
||||
/>
|
||||
<AutoField label="Genre" value={project.genre} onCommit={(genre) => update.mutate({ genre })} />
|
||||
</div>
|
||||
<AutoField
|
||||
label="Logline"
|
||||
value={project.logline}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="Who wants what, and what stands in the way."
|
||||
onCommit={(logline) => update.mutate({ logline })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Synopsis"
|
||||
value={project.synopsis}
|
||||
multiline
|
||||
rows={8}
|
||||
serif
|
||||
placeholder="The whole story in a few paragraphs, ending included."
|
||||
onCommit={(synopsis) => update.mutate({ synopsis })}
|
||||
/>
|
||||
<AutoField
|
||||
label="Notes"
|
||||
value={project.notes}
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="Theme, tone, comparable titles, research threads."
|
||||
onCommit={(notes) => update.mutate({ notes })}
|
||||
/>
|
||||
<label className="block max-w-48">
|
||||
<span className="label">Target word count</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
defaultValue={project.targetWordCount ?? ''}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value)
|
||||
if (Number.isFinite(value) && value !== project.targetWordCount) {
|
||||
update.mutate({ targetWordCount: value || null })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{update.error && (
|
||||
<div className="mt-3">
|
||||
<ErrorNote error={update.error} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="grid content-start gap-4">
|
||||
<div className="card p-5">
|
||||
<h2 className="mb-3 text-sm font-semibold tracking-wide uppercase muted">Progress</h2>
|
||||
<p className="text-2xl font-semibold">{drafted.toLocaleString()}</p>
|
||||
<p className="text-sm muted">
|
||||
words drafted{target > 0 && ` of ${target.toLocaleString()}`}
|
||||
</p>
|
||||
{percent !== null && (
|
||||
<div
|
||||
className="mt-3 h-1.5 overflow-hidden rounded-full"
|
||||
style={{ background: 'var(--surface-sunken)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${percent}%`, background: 'var(--accent)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="muted">Characters</dt>
|
||||
<dd className="font-medium">{characters?.length ?? 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="muted">Chapters</dt>
|
||||
<dd className="font-medium">{chapters?.length ?? 0}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2>
|
||||
<p className="mb-3 text-sm muted">
|
||||
Deleting a novel removes its outline, characters, chapters and conversations.
|
||||
</p>
|
||||
<button
|
||||
className="btn w-full"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete “${project.title}” and everything in it? This cannot be undone.`)) {
|
||||
remove.mutate(projectId, { onSuccess: () => navigate('/') })
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete this novel
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NavLink, Outlet, useParams, Link } from 'react-router-dom'
|
||||
import { useProject } from '../api/hooks'
|
||||
import { ErrorNote, Spinner } from '../components/ui'
|
||||
|
||||
const tabs = [
|
||||
{ to: '', label: 'Overview', end: true },
|
||||
{ to: 'outline', label: 'Outline' },
|
||||
{ to: 'characters', label: 'Characters' },
|
||||
{ to: 'chapters', label: 'Chapters' },
|
||||
{ to: 'agent', label: 'Agent' },
|
||||
]
|
||||
|
||||
export default function ProjectLayout() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: project, isPending, error } = useProject(projectId)
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-4 px-6 py-3">
|
||||
<Link to="/" className="text-sm muted hover:underline">
|
||||
← Novels
|
||||
</Link>
|
||||
<h1 className="truncate text-base font-semibold">{project?.title ?? '…'}</h1>
|
||||
</div>
|
||||
<nav className="mx-auto flex max-w-6xl gap-1 px-4">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.label}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) =>
|
||||
`border-b-2 px-3 py-2 text-sm font-medium transition ${
|
||||
isActive ? 'border-current' : 'border-transparent'
|
||||
}`
|
||||
}
|
||||
style={({ isActive }) => ({ color: isActive ? 'var(--accent)' : 'var(--ink-muted)' })}
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
{error && <ErrorNote error={error} />}
|
||||
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useCreateProject, useProjects } from '../api/hooks'
|
||||
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { data: projects, isPending, error } = useProjects()
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<header className="mb-8 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Your novels</h1>
|
||||
<p className="mt-1 text-sm muted">
|
||||
Outlines, character dossiers, and a writing partner that knows the book.
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
New novel
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <ErrorNote error={error} />}
|
||||
{isPending && <Spinner label="Loading projects" />}
|
||||
|
||||
{projects?.length === 0 && (
|
||||
<EmptyState
|
||||
title="Nothing here yet"
|
||||
hint="Start with a title and a one-sentence logline. Everything else can come later."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{projects?.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
to={`/projects/${project.id}`}
|
||||
className="card block px-5 py-4 transition hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{project.title}</h2>
|
||||
<span className="text-xs muted">
|
||||
{project.genre ?? 'Uncategorised'}
|
||||
{project.author && ` · ${project.author}`}
|
||||
</span>
|
||||
</div>
|
||||
{project.logline && <p className="mt-1 text-sm muted">{project.logline}</p>}
|
||||
<div className="mt-3 flex gap-4 text-xs muted">
|
||||
<span>{project.characterCount} characters</span>
|
||||
<span>{project.chapterCount} chapters</span>
|
||||
<span>
|
||||
{project.wordCount.toLocaleString()}
|
||||
{project.targetWordCount
|
||||
? ` / ${project.targetWordCount.toLocaleString()} words`
|
||||
: ' words'}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const create = useCreateProject()
|
||||
const [title, setTitle] = useState('')
|
||||
const [author, setAuthor] = useState('')
|
||||
const [genre, setGenre] = useState('')
|
||||
const [logline, setLogline] = useState('')
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
create.mutate(
|
||||
{
|
||||
title: title.trim(),
|
||||
author: author.trim() || undefined,
|
||||
genre: genre.trim() || undefined,
|
||||
logline: logline.trim() || undefined,
|
||||
},
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="New novel" onClose={onClose}>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
<label className="block">
|
||||
<span className="label">Title</span>
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Working title"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="label">Author</span>
|
||||
<input className="input" value={author} onChange={(e) => setAuthor(e.target.value)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Genre</span>
|
||||
<input className="input" value={genre} onChange={(e) => setGenre(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="label">Logline</span>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={logline}
|
||||
onChange={(e) => setLogline(e.target.value)}
|
||||
placeholder="One sentence: who wants what, and what stands in the way."
|
||||
/>
|
||||
</label>
|
||||
|
||||
{create.error && <ErrorNote error={create.error} />}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={!title.trim() || create.isPending}>
|
||||
{create.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// Proxy the API in dev so the browser sees a single origin and CORS never enters
|
||||
// the picture during local development.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL ?? 'http://localhost:5080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NovelSoftware.Application;
|
||||
using NovelSoftware.Application.Agent;
|
||||
using NovelSoftware.Infrastructure.Anthropic;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class AnthropicClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructing_without_a_key_does_not_throw()
|
||||
{
|
||||
// The agent service takes this as a dependency and also serves read-only endpoints
|
||||
// (listing conversations, reading a transcript). Throwing at construction would
|
||||
// take those down on any install that has not configured a key yet.
|
||||
var construct = () => new AnthropicAgentModelClient(Options.Create(new AgentOptions()));
|
||||
|
||||
construct.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sending_without_a_key_reports_a_configuration_problem()
|
||||
{
|
||||
var previous = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
|
||||
Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", null);
|
||||
|
||||
try
|
||||
{
|
||||
var client = new AnthropicAgentModelClient(Options.Create(new AgentOptions()));
|
||||
|
||||
var send = async () => await client.CompleteAsync("system", [], []);
|
||||
|
||||
(await send.Should().ThrowAsync<AgentNotConfiguredException>())
|
||||
.WithMessage("*ANTHROPIC_API_KEY*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NovelSoftware.Application.Agent;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the list endpoints, which sort and aggregate in SQL rather than in memory.
|
||||
/// SQLite is fussier than the in-memory provider about what it will translate — ordering
|
||||
/// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
|
||||
/// </summary>
|
||||
public class ListingTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly ProjectService _projects;
|
||||
private readonly ChapterService _chapters;
|
||||
private readonly SceneService _scenes;
|
||||
private readonly CharacterService _characters;
|
||||
|
||||
public ListingTests()
|
||||
{
|
||||
_projects = new ProjectService(_db.Context);
|
||||
_chapters = new ChapterService(_db.Context);
|
||||
_scenes = new SceneService(_db.Context);
|
||||
_characters = new CharacterService(_db.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Projects_are_listed_most_recently_updated_first()
|
||||
{
|
||||
var older = await _projects.CreateAsync(new CreateProjectRequest("Older Book"));
|
||||
var newer = await _projects.CreateAsync(new CreateProjectRequest("Newer Book"));
|
||||
|
||||
// Touching the older project should float it to the top.
|
||||
await _projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
|
||||
|
||||
var listed = await _projects.ListAsync();
|
||||
|
||||
listed.Select(p => p.Title).Should().Equal("Older Book", "Newer Book");
|
||||
listed.Select(p => p.Id).Should().Contain(newer.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Project_summaries_aggregate_counts_and_words_across_chapters()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
|
||||
|
||||
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
|
||||
var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
|
||||
await _scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
|
||||
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
|
||||
|
||||
var summary = (await _projects.ListAsync()).Single();
|
||||
|
||||
summary.CharacterCount.Should().Be(2);
|
||||
summary.ChapterCount.Should().Be(2);
|
||||
summary.WordCount.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing()
|
||||
{
|
||||
await _projects.CreateAsync(new CreateProjectRequest("Empty"));
|
||||
|
||||
var summary = (await _projects.ListAsync()).Single();
|
||||
|
||||
summary.WordCount.Should().Be(0);
|
||||
summary.ChapterCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
|
||||
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
|
||||
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
|
||||
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
|
||||
|
||||
var listed = await _chapters.ListAsync(project.Id);
|
||||
|
||||
listed.Select(c => c.Title).Should().Equal("First", "Second");
|
||||
listed.Single(c => c.Id == second.Id).SceneCount.Should().Be(2);
|
||||
listed.Single(c => c.Id == second.Id).WordCount.Should().Be(3);
|
||||
listed.Single(c => c.Id == first.Id).WordCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Conversations_are_listed_most_recently_updated_first()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
var agent = new NovelAgentService(
|
||||
_db.Context,
|
||||
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
|
||||
new NovelAgentToolset(_projects, _characters, new OutlineService(_db.Context), _chapters, _scenes),
|
||||
Options.Create(new AgentOptions()),
|
||||
NullLogger<NovelAgentService>.Instance);
|
||||
|
||||
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question."));
|
||||
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question."));
|
||||
|
||||
var listed = await agent.ListConversationsAsync(project.Id);
|
||||
|
||||
listed.Should().HaveCount(2);
|
||||
listed[0].Title.Should().Be("Second question.");
|
||||
listed[0].MessageCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Characters_are_listed_by_role_then_name()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Zeno", NovelSoftware.Domain.CharacterRole.Supporting));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Ines", NovelSoftware.Domain.CharacterRole.Protagonist));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Anders", NovelSoftware.Domain.CharacterRole.Supporting));
|
||||
|
||||
var listed = await _characters.ListAsync(project.Id);
|
||||
|
||||
listed.Select(c => c.Name).Should().Equal("Ines", "Anders", "Zeno");
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NovelSoftware.Application.Agent;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class NovelAgentServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly ProjectService _projects;
|
||||
private readonly CharacterService _characters;
|
||||
private readonly NovelAgentToolset _toolset;
|
||||
|
||||
public NovelAgentServiceTests()
|
||||
{
|
||||
_projects = new ProjectService(_db.Context);
|
||||
_characters = new CharacterService(_db.Context);
|
||||
_toolset = new NovelAgentToolset(
|
||||
_projects,
|
||||
_characters,
|
||||
new OutlineService(_db.Context),
|
||||
new ChapterService(_db.Context),
|
||||
new SceneService(_db.Context));
|
||||
}
|
||||
|
||||
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
||||
_db.Context,
|
||||
model,
|
||||
_toolset,
|
||||
Options.Create(new AgentOptions { MaxIterations = 4 }),
|
||||
NullLogger<NovelAgentService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task A_plain_reply_is_persisted_as_a_conversation()
|
||||
{
|
||||
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]);
|
||||
var agent = BuildAgent(model);
|
||||
|
||||
var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?"));
|
||||
|
||||
turn.Message.Content.Should().Be("Tell me about the ending.");
|
||||
|
||||
var conversation = await agent.GetConversationAsync(turn.ConversationId);
|
||||
conversation.Messages.Should().HaveCount(2);
|
||||
conversation.Messages[0].Content.Should().Be("Where do I start?");
|
||||
conversation.Title.Should().Be("Where do I start?");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tool_calls_are_executed_against_real_project_data()
|
||||
{
|
||||
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })],
|
||||
[new AgentTextBlock("Added Ines as the protagonist.")]
|
||||
]);
|
||||
|
||||
var turn = await BuildAgent(model).SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Add a protagonist called Ines."));
|
||||
|
||||
var characters = await _characters.ListAsync(projectId);
|
||||
characters.Should().ContainSingle().Which.Name.Should().Be("Ines");
|
||||
|
||||
turn.Message.Content.Should().Be("Added Ines as the protagonist.");
|
||||
turn.Message.ToolCalls.Should().ContainSingle().Which.Name.Should().Be("create_character");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_tool_result_comes_back_in_a_single_user_turn()
|
||||
{
|
||||
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[
|
||||
ToolUse("t1", "create_character", new { name = "Ines" }),
|
||||
ToolUse("t2", "create_character", new { name = "Mara" })
|
||||
],
|
||||
[new AgentTextBlock("Both added.")]
|
||||
]);
|
||||
|
||||
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters."));
|
||||
|
||||
var secondRequest = model.Transcripts[1];
|
||||
var resultTurn = secondRequest[^1];
|
||||
|
||||
resultTurn.Role.Should().Be("user");
|
||||
resultTurn.Content.OfType<AgentToolResultBlock>().Should().HaveCount(2);
|
||||
(await _characters.ListAsync(projectId)).Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_tool_is_reported_back_rather_than_thrown()
|
||||
{
|
||||
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })],
|
||||
[new AgentTextBlock("That character does not exist yet — shall I create her?")]
|
||||
]);
|
||||
|
||||
var turn = await BuildAgent(model).SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Rename her."));
|
||||
|
||||
var errorResult = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
|
||||
errorResult.IsError.Should().BeTrue();
|
||||
errorResult.Content.Should().Contain("was not found");
|
||||
|
||||
turn.Message.Content.Should().Contain("does not exist yet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tools_are_reported_without_breaking_the_loop()
|
||||
{
|
||||
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[ToolUse("t1", "summon_muse", new { })],
|
||||
[new AgentTextBlock("Sorry — I do not have that tool.")]
|
||||
]);
|
||||
|
||||
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse."));
|
||||
|
||||
var result = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
|
||||
result.IsError.Should().BeTrue();
|
||||
result.Content.Should().Contain("No such tool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_loop_stops_at_the_iteration_ceiling()
|
||||
{
|
||||
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
// A model that only ever asks for more tools would otherwise loop forever.
|
||||
var model = new ScriptedModelClient(
|
||||
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
|
||||
[ToolUse("t", "list_characters", new { })], 20).ToList());
|
||||
|
||||
var turn = await BuildAgent(model).SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Keep going forever."));
|
||||
|
||||
model.Transcripts.Should().HaveCount(4);
|
||||
turn.Message.Content.Should().Contain("tool-call limit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Follow_up_messages_continue_the_same_conversation()
|
||||
{
|
||||
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
var model = new ScriptedModelClient([
|
||||
[new AgentTextBlock("First answer.")],
|
||||
[new AgentTextBlock("Second answer.")]
|
||||
]);
|
||||
var agent = BuildAgent(model);
|
||||
|
||||
var first = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Question one."));
|
||||
var second = await agent.SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId));
|
||||
|
||||
second.ConversationId.Should().Be(first.ConversationId);
|
||||
|
||||
// The second request replays the earlier turns so the model has the history.
|
||||
model.Transcripts[1].Should().HaveCount(3);
|
||||
model.Transcripts[1].Select(m => m.Role).Should().Equal("user", "assistant", "user");
|
||||
|
||||
var conversation = await agent.GetConversationAsync(first.ConversationId);
|
||||
conversation.Messages.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_tool_declares_an_object_schema_and_a_description()
|
||||
{
|
||||
_toolset.Definitions.Should().NotBeEmpty();
|
||||
|
||||
foreach (var tool in _toolset.Definitions)
|
||||
{
|
||||
tool.Description.Should().NotBeNullOrWhiteSpace();
|
||||
tool.InputSchema.GetProperty("type").GetString().Should().Be("object");
|
||||
tool.InputSchema.TryGetProperty("properties", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
_toolset.Definitions.Select(t => t.Name).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
private static AgentToolUseBlock ToolUse(string id, string name, object input) =>
|
||||
new(id, name, JsonSerializer.SerializeToElement(input));
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A model stand-in that returns a fixed script of turns and records every transcript it
|
||||
/// was sent, so tests can assert on what the loop actually put in front of the model.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script)
|
||||
: IAgentModelClient
|
||||
{
|
||||
private int _turn;
|
||||
|
||||
public List<IReadOnlyList<AgentChatMessage>> Transcripts { get; } = [];
|
||||
|
||||
public Task<AgentModelResponse> CompleteAsync(
|
||||
string systemPrompt,
|
||||
IReadOnlyList<AgentChatMessage> messages,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Transcripts.Add([.. messages]);
|
||||
|
||||
var content = _turn < script.Count ? script[_turn] : [new AgentTextBlock("(no more script)")];
|
||||
_turn++;
|
||||
|
||||
var stopReason = content.OfType<AgentToolUseBlock>().Any() ? "tool_use" : "end_turn";
|
||||
return Task.FromResult(new AgentModelResponse(content, stopReason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NovelSoftware.Infrastructure\NovelSoftware.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\NovelSoftware.Api\NovelSoftware.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,121 @@
|
||||
using FluentAssertions;
|
||||
using NovelSoftware.Application;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
using NovelSoftware.Domain;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class OutlineServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly OutlineService _outlines;
|
||||
private readonly Guid _projectId;
|
||||
|
||||
public OutlineServiceTests()
|
||||
{
|
||||
_outlines = new OutlineService(_db.Context);
|
||||
_projectId = new ProjectService(_db.Context)
|
||||
.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nested_nodes_come_back_as_a_tree()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Act One", OutlineNodeType.Act));
|
||||
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"She finds the map", OutlineNodeType.Beat, ParentId: act.Id));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"The harbour burns", OutlineNodeType.Beat, ParentId: act.Id));
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
|
||||
tree.Should().ContainSingle();
|
||||
tree[0].Title.Should().Be("Act One");
|
||||
tree[0].Children.Select(c => c.Title)
|
||||
.Should().Equal("She finds the map", "The harbour burns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sibling_order_follows_sort_order_not_insertion_order()
|
||||
{
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Third", SortOrder: 30));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("First", SortOrder: 10));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Second", SortOrder: 20));
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
|
||||
tree.Select(n => n.Title).Should().Equal("First", "Second", "Third");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_a_node_under_its_own_descendant_is_rejected()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Sequence", ParentId: act.Id));
|
||||
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Beat", ParentId: sequence.Id));
|
||||
|
||||
var move = async () => await _outlines.MoveAsync(act.Id, new MoveOutlineNodeRequest(beat.Id, 1));
|
||||
|
||||
await move.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*beneath its own descendant*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_node_cannot_be_its_own_parent()
|
||||
{
|
||||
var node = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
|
||||
var move = async () => await _outlines.MoveAsync(node.Id, new MoveOutlineNodeRequest(node.Id, 1));
|
||||
|
||||
await move.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*its own parent*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_to_the_root_detaches_from_the_old_parent()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Beat", ParentId: act.Id));
|
||||
|
||||
await _outlines.MoveAsync(beat.Id, new MoveOutlineNodeRequest(null, 2));
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
tree.Should().HaveCount(2);
|
||||
tree.Single(n => n.Title == "Act One").Children.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_node_takes_its_whole_subtree()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Sequence", ParentId: act.Id));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Beat", ParentId: sequence.Id));
|
||||
var survivor = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act Two"));
|
||||
|
||||
await _outlines.DeleteAsync(act.Id);
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
tree.Should().ContainSingle().Which.Id.Should().Be(survivor.Id);
|
||||
|
||||
using var verification = _db.CreateContext();
|
||||
verification.OutlineNodes.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creating_under_a_missing_parent_reports_not_found()
|
||||
{
|
||||
var create = async () => await _outlines.CreateAsync(_projectId,
|
||||
new CreateOutlineNodeRequest("Orphan", ParentId: Guid.NewGuid()));
|
||||
|
||||
await create.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Application;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
using NovelSoftware.Domain;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class ProjectDataTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly ProjectService _projects;
|
||||
private readonly CharacterService _characters;
|
||||
private readonly ChapterService _chapters;
|
||||
private readonly SceneService _scenes;
|
||||
|
||||
public ProjectDataTests()
|
||||
{
|
||||
_projects = new ProjectService(_db.Context);
|
||||
_characters = new CharacterService(_db.Context);
|
||||
_chapters = new ChapterService(_db.Context);
|
||||
_scenes = new SceneService(_db.Context);
|
||||
}
|
||||
|
||||
private async Task<Guid> NewProjectAsync() =>
|
||||
(await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
[Fact]
|
||||
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
||||
{
|
||||
var id = (await _projects.CreateAsync(
|
||||
new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id;
|
||||
|
||||
var afterPartialUpdate = await _projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road"));
|
||||
|
||||
afterPartialUpdate.Title.Should().Be("The Salt Road");
|
||||
afterPartialUpdate.Genre.Should().Be("Fantasy");
|
||||
afterPartialUpdate.Logline.Should().Be("A cartographer goes to sea.");
|
||||
|
||||
var afterClear = await _projects.UpdateAsync(id, new UpdateProjectRequest(Genre: ""));
|
||||
|
||||
afterClear.Genre.Should().BeNull();
|
||||
afterClear.Logline.Should().Be("A cartographer goes to sea.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
|
||||
{
|
||||
var projectId = await NewProjectAsync();
|
||||
|
||||
var first = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
||||
var second = await _chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour"));
|
||||
|
||||
first.Number.Should().Be(1);
|
||||
second.Number.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Word_count_is_recomputed_whenever_prose_changes()
|
||||
{
|
||||
var projectId = await NewProjectAsync();
|
||||
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
||||
|
||||
var scene = await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
|
||||
"The dock at dawn", Prose: "Five words go right here"));
|
||||
|
||||
scene.WordCount.Should().Be(5);
|
||||
|
||||
var rewritten = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
|
||||
Prose: "Now\nthere are seven words in total"));
|
||||
|
||||
rewritten.WordCount.Should().Be(7);
|
||||
|
||||
var cleared = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: ""));
|
||||
|
||||
cleared.Prose.Should().BeNull();
|
||||
cleared.WordCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched()
|
||||
{
|
||||
var projectId = await NewProjectAsync();
|
||||
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
||||
var scene = await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
|
||||
"The dock at dawn", Prose: "The tide came in slow."));
|
||||
|
||||
var updated = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised));
|
||||
|
||||
updated.Prose.Should().Be("The tide came in slow.");
|
||||
updated.WordCount.Should().Be(5);
|
||||
updated.Status.Should().Be(DraftStatus.Revised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes()
|
||||
{
|
||||
var projectId = await NewProjectAsync();
|
||||
await _characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
|
||||
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
||||
await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn"));
|
||||
|
||||
await _projects.DeleteAsync(projectId);
|
||||
|
||||
using var verification = _db.CreateContext();
|
||||
(await verification.Projects.CountAsync()).Should().Be(0);
|
||||
(await verification.Characters.CountAsync()).Should().Be(0);
|
||||
(await verification.Chapters.CountAsync()).Should().Be(0);
|
||||
(await verification.Scenes.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relating_characters_across_projects_is_refused()
|
||||
{
|
||||
var firstProject = await NewProjectAsync();
|
||||
var secondProject = (await _projects.CreateAsync(new CreateProjectRequest("Other Book"))).Id;
|
||||
|
||||
var ines = await _characters.CreateAsync(firstProject, new CreateCharacterRequest("Ines"));
|
||||
var stranger = await _characters.CreateAsync(secondProject, new CreateCharacterRequest("Stranger"));
|
||||
|
||||
var relate = async () => await _characters.AddRelationshipAsync(
|
||||
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister"));
|
||||
|
||||
await relate.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*same project*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relationships_resolve_the_other_character_by_name()
|
||||
{
|
||||
var projectId = await NewProjectAsync();
|
||||
var ines = await _characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
|
||||
var mara = await _characters.CreateAsync(projectId, new CreateCharacterRequest("Mara"));
|
||||
|
||||
var updated = await _characters.AddRelationshipAsync(
|
||||
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire."));
|
||||
|
||||
updated.Relationships.Should().ContainSingle()
|
||||
.Which.RelatedCharacterName.Should().Be("Mara");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_missing_project_reports_not_found()
|
||||
{
|
||||
var get = async () => await _projects.GetAsync(Guid.NewGuid());
|
||||
|
||||
await get.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Infrastructure.Persistence;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A throwaway SQLite database held in memory. Using real SQLite rather than the
|
||||
/// in-memory provider means the tests exercise the same relational behaviour the app
|
||||
/// ships with — cascade deletes, foreign keys and all.
|
||||
/// </summary>
|
||||
public sealed class TestDatabase : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public TestDatabase()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
Context = CreateContext();
|
||||
Context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public NovelDbContext Context { get; }
|
||||
|
||||
/// <summary>A second context over the same database, for asserting on persisted state.</summary>
|
||||
public NovelDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Context.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user