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:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 3c85bab4a4
commit 0d7b7a6f30
91 changed files with 9935 additions and 1 deletions
+8
View File
@@ -427,3 +427,11 @@ FodyWeavers.xsd
*.msix *.msix
*.msm *.msm
*.msp *.msp
## Novel Software
node_modules/
dist/
*.db
*.db-shm
*.db-wal
mcp-server/
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"novel-software": {
"command": "./mcp-server/NovelSoftware.Mcp",
"env": {
"NOVELSOFTWARE_API_URL": "http://localhost:5080"
}
}
}
}
+12
View File
@@ -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>
+174 -1
View File
@@ -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>
+78
View File
@@ -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"
}
}
}
+22
View File
@@ -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;
}
+45
View File
@@ -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>
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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();
});
}
}
+104
View File
@@ -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>
+27
View File
@@ -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);
}
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+32
View File
@@ -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.
+12
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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

+24
View File
@@ -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

+26
View File
@@ -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>
)
}
+48
View File
@@ -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' }),
}
+231
View File
@@ -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) })
},
})
}
+175
View File
@@ -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
}
+189
View File
@@ -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>
)
}
+134
View File
@@ -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;
}
}
+22
View File
@@ -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
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+18
View File
@@ -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);
}
}
}
+131
View File
@@ -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();
}
+36
View File
@@ -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();
}
}