Compare commits
13
Commits
3795ddd541
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af65fd22a9 | ||
|
|
e1df79b31e | ||
|
|
2d6bb9fc57 | ||
|
|
ab773615f8 | ||
|
|
897fb442a1 | ||
|
|
bb2a499569 | ||
|
|
64fff4f1f7 | ||
|
|
13fa29e8e9 | ||
|
|
aca26588f9 | ||
|
|
7df1fffdca | ||
|
|
1423977ed4 | ||
|
|
661f2917ea | ||
|
|
e3d410da0b |
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: outline-importer
|
name: outline-importer
|
||||||
description: Imports an author's existing novel outline (chapters + character dossiers, in the Kingdom Sleeps folder format) into a Novelly project over the MCP server. Invoke explicitly with a source folder path — this agent does not run proactively.
|
description: Imports an author's existing novel outline (chapters + character dossiers, in the Kingdom Sleeps folder format) into a Novelly project over the MCP server. Invoke explicitly with a source folder path — this agent does not run proactively.
|
||||||
tools: Read, Glob, Grep, Write, mcp__novelly__list_projects, mcp__novelly__get_project_brief, mcp__novelly__create_project, mcp__novelly__update_project_brief, mcp__novelly__list_chapters, mcp__novelly__get_chapter, mcp__novelly__create_chapter, mcp__novelly__update_chapter, mcp__novelly__get_chapter_outline, mcp__novelly__create_beat, mcp__novelly__update_beat, mcp__novelly__list_characters, mcp__novelly__get_character, mcp__novelly__create_character, mcp__novelly__update_character, mcp__novelly__get_character_arc, mcp__novelly__add_arc_stage, mcp__novelly__list_tags
|
tools: Read, Glob, Grep, Write, mcp__novelly__list_novels, mcp__novelly__get_novel_brief, mcp__novelly__create_novel, mcp__novelly__update_novel_brief, mcp__novelly__list_chapters, mcp__novelly__get_chapter, mcp__novelly__create_chapter, mcp__novelly__update_chapter, mcp__novelly__get_chapter_outline, mcp__novelly__create_beat, mcp__novelly__update_beat, mcp__novelly__list_characters, mcp__novelly__get_character, mcp__novelly__create_character, mcp__novelly__update_character, mcp__novelly__get_character_arc, mcp__novelly__add_arc_stage, mcp__novelly__list_tags
|
||||||
model: inherit
|
model: inherit
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,9 +101,9 @@ in your final report.
|
|||||||
Do not skip ahead — each pass depends on ids the previous one minted. If you're picking up a
|
Do not skip ahead — each pass depends on ids the previous one minted. If you're picking up a
|
||||||
resumed run, jump straight to the first incomplete pass.
|
resumed run, jump straight to the first incomplete pass.
|
||||||
|
|
||||||
**0. Preflight.** Call `list_projects` to confirm the API is reachable at all — if this fails, stop
|
**0. Preflight.** Call `list_novels` to confirm the API is reachable at all — if this fails, stop
|
||||||
and tell the user to start the API (`ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`)
|
and tell the user to start the API (`ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`).
|
||||||
and that `.mcp.json` must point at a published `Novelly.Mcp` binary. Glob the source root for
|
Glob the source root for
|
||||||
`outline.md`, `outlines/*.md` or `chapters/*.md`, and `characters/*.md`. If `outline.md` is
|
`outline.md`, `outlines/*.md` or `chapters/*.md`, and `characters/*.md`. If `outline.md` is
|
||||||
missing, stop — that's the one file every pass depends on. Report the file counts found before
|
missing, stop — that's the one file every pass depends on. Report the file counts found before
|
||||||
proceeding.
|
proceeding.
|
||||||
@@ -111,7 +111,7 @@ proceeding.
|
|||||||
**1. Project.** Skip if `completedPasses` already has `"project"`. Read `outline.md`. Its heading is
|
**1. Project.** Skip if `completedPasses` already has `"project"`. Read `outline.md`. Its heading is
|
||||||
`# Outline — <Title> (<Author>)` or similar — parse title and author out of it; if there's no
|
`# Outline — <Title> (<Author>)` or similar — parse title and author out of it; if there's no
|
||||||
author, leave it null. The paragraph(s) before the chapter table are the blurb — pass as `notes`
|
author, leave it null. The paragraph(s) before the chapter table are the blurb — pass as `notes`
|
||||||
argument to `create_project` (there's no dedicated blurb field; `synopsis` may be filled in later
|
argument to `create_novel` (there's no dedicated blurb field; `synopsis` may be filled in later
|
||||||
by the author). Record `projectId` in the ledger, mark `"project"` complete.
|
by the author). Record `projectId` in the ledger, mark `"project"` complete.
|
||||||
|
|
||||||
**2. Characters — dossier fields only, not arcs yet.** Skip files whose name (matched
|
**2. Characters — dossier fields only, not arcs yet.** Skip files whose name (matched
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ jobs:
|
|||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
WEB_PORT: ${{ vars.WEB_PORT }}
|
WEB_PORT: ${{ vars.WEB_PORT }}
|
||||||
|
API_PORT: ${{ vars.API_PORT }}
|
||||||
|
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout@v4 is a Node-based action; this runner has no node in PATH, so
|
# actions/checkout@v4 is a Node-based action; this runner has no node in PATH, so
|
||||||
# checkout plain git instead of via marketplace action.
|
# checkout plain git instead of via marketplace action.
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
*.env
|
*.env
|
||||||
.env.deploy
|
.env.deploy
|
||||||
|
|
||||||
|
# Local outline drop-box for the import feature (Imports:RootPath)
|
||||||
|
/imports/
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
*.userprefs
|
*.userprefs
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"novelly": {
|
"novelly": {
|
||||||
"command": "./mcp-server/Novelly.Mcp",
|
"type": "http",
|
||||||
"env": {
|
"url": "http://localhost:5080/mcp",
|
||||||
"NOVELLY_API_URL": "http://localhost:5080",
|
"headers": {
|
||||||
"NOVELLY_API_KEY": "<matches the API's Auth:ServiceApiKey user secret>"
|
"X-Novelly-Api-Key": "<matches the API's Auth:ServiceApiKey user secret>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ Novelly: software plan + write novel. ASP.NET Core 10, C#, TypeScript, React, .N
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds
|
- `src/Novelly.Api/` — whole back end, organised by feature, plus the MCP endpoint. One folder
|
||||||
entity, DTOs, service, endpoints together: `Novels/`, `Characters/`, `Chapters/`, `Beats/`,
|
per feature holds entity, DTOs, service, endpoints together: `Novels/`, `Characters/`,
|
||||||
`Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds
|
`Chapters/`, `Beats/`, `Scenes/`, `Tags/`, `Agent/`, `Mcp/`. `Common/` holds what crosses
|
||||||
`DbContext` + EF migrations.
|
features; `Data/` holds `DbContext` + EF migrations.
|
||||||
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client
|
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client
|
||||||
- `src/Novelly.ServiceDefaults/` — shared Aspire wiring: OpenTelemetry, health checks, service discovery
|
- `src/Novelly.ServiceDefaults/` — shared Aspire wiring: OpenTelemetry, health checks, service discovery
|
||||||
- `src/Novelly.Mcp/` — MCP stdio server
|
|
||||||
- `src/Novelly.Web/` — React + Vite client
|
- `src/Novelly.Web/` — React + Vite client
|
||||||
- `tests/` — test suite
|
- `tests/` — test suite
|
||||||
- `docs/` — documentation
|
- `docs/` — documentation
|
||||||
@@ -59,6 +58,7 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A
|
|||||||
- `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
|
- `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
|
||||||
- Enums cross wire as names, never ordinals
|
- Enums cross wire as names, never ordinals
|
||||||
- All frontend components should have an id attribute that identifies them uniquely.
|
- All frontend components should have an id attribute that identifies them uniquely.
|
||||||
|
- Web client is keyboard-first: read `docs/keyboard.md` before adding any interactive UI (forms, editable rows, create flows).
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ Build + tests passing ≠ working. Anything touching endpoint, agent loop, or MC
|
|||||||
Vite dev server on :5173, dashboard for logs + traces
|
Vite dev server on :5173, dashboard for logs + traces
|
||||||
- API alone: `ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`, then exercise route with curl
|
- API alone: `ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`, then exercise route with curl
|
||||||
- Web alone: `cd src/Novelly.Web && npm run dev` — proxies `/api` to :5080
|
- Web alone: `cd src/Novelly.Web && npm run dev` — proxies `/api` to :5080
|
||||||
- MCP: build it, then drive over stdio JSON-RPC (`initialize` → `notifications/initialized` → `tools/list` → `tools/call`)
|
- MCP: with the API running, drive `/mcp` over HTTP (`initialize` → `notifications/initialized` → `tools/list` → `tools/call`) — Streamable HTTP, so responses are SSE-framed and requests need `Accept: application/json, text/event-stream`
|
||||||
|
|
||||||
Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's model client throwing at construction + taking read-only endpoints down with it — passed build + test suite, only showed up when app actually ran.
|
Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's model client throwing at construction + taking read-only endpoints down with it — passed build + test suite, only showed up when app actually ran.
|
||||||
|
|
||||||
@@ -103,5 +103,6 @@ Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's mode
|
|||||||
- Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode.
|
- Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode.
|
||||||
- API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. App must stay fully usable without key; only agent endpoints require it.
|
- API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. App must stay fully usable without key; only agent endpoints require it.
|
||||||
- EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. API migrates on boot.
|
- EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. API migrates on boot.
|
||||||
|
- Outline import root lives in `appsettings.json` under `Imports:RootPath` (`Imports__RootPath` env var). When set, it's the only folder the browse/upload import endpoints and the source picker can reach; unset, those endpoints are disabled and the dialog falls back to a typed path with no sandbox. Created at boot if missing.
|
||||||
- `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then web build. Run `npm install`
|
- `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then web build. Run `npm install`
|
||||||
once at repo root to install hook.
|
once at repo root to install hook.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/Novelly.Api/Novelly.Api.csproj" />
|
<Project Path="src/Novelly.Api/Novelly.Api.csproj" />
|
||||||
<Project Path="src/Novelly.AppHost/Novelly.AppHost.csproj" />
|
<Project Path="src/Novelly.AppHost/Novelly.AppHost.csproj" />
|
||||||
<Project Path="src/Novelly.Mcp/Novelly.Mcp.csproj" />
|
|
||||||
<Project Path="src/Novelly.ServiceDefaults/Novelly.ServiceDefaults.csproj" />
|
<Project Path="src/Novelly.ServiceDefaults/Novelly.ServiceDefaults.csproj" />
|
||||||
<Project Path="src/Novelly.Web/Novelly.Web.esproj">
|
<Project Path="src/Novelly.Web/Novelly.Web.esproj">
|
||||||
<Build />
|
<Build />
|
||||||
|
|||||||
@@ -6,22 +6,21 @@
|
|||||||
|
|
||||||
Software for planning and writing a novel. You outline the book, keep character
|
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
|
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
|
embedded in the app that can read and edit the same data you can, and an MCP endpoint that
|
||||||
exposes that data to Claude Code, Claude Desktop, or any other MCP client.
|
exposes that same 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
|
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
|
React UI's REST calls, the embedded agent, and MCP clients all resolve to the same
|
||||||
edit made from a chat in Claude Code and an edit made by typing in the browser are the
|
application services in-process, so an edit made from a chat in Claude Code and an edit
|
||||||
same edit.
|
made by typing in the browser are the same edit.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Piece | Built with |
|
| Piece | Built with |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Novelly.Api` | ASP.NET Core 10 minimal APIs, EF Core 10 + SQLite, Anthropic SDK, OpenAPI |
|
| `Novelly.Api` | ASP.NET Core 10 minimal APIs, EF Core 10 + SQLite, Anthropic SDK, OpenAPI, MCP over Streamable HTTP (`ModelContextProtocol.AspNetCore`) |
|
||||||
| `Novelly.AppHost` | .NET Aspire orchestration for the API and the web client |
|
| `Novelly.AppHost` | .NET Aspire orchestration for the API and the web client |
|
||||||
| `Novelly.ServiceDefaults` | Shared OpenTelemetry, health checks and service discovery |
|
| `Novelly.ServiceDefaults` | Shared OpenTelemetry, health checks and service discovery |
|
||||||
| `Novelly.Mcp` | MCP stdio server (`ModelContextProtocol`) |
|
|
||||||
| `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 |
|
| `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 |
|
||||||
|
|
||||||
The back end is one project organised by feature, not by layer. Each feature folder —
|
The back end is one project organised by feature, not by layer. Each feature folder —
|
||||||
@@ -192,19 +191,10 @@ A few deliberate choices worth knowing about:
|
|||||||
|
|
||||||
## The MCP server
|
## The MCP server
|
||||||
|
|
||||||
A stdio MCP server exposing 45 tools over the same REST API. It holds no domain logic of
|
The API itself serves MCP over Streamable HTTP at `POST /mcp`, exposing 45 tools that call
|
||||||
its own — it is a second front end, not a second implementation.
|
the same application services the REST endpoints and the embedded web agent call — it holds
|
||||||
|
no domain logic of its own, and there's nothing to build or publish separately. The API
|
||||||
Build it, then point your MCP client at the produced binary:
|
process just needs to be running; there's no separate subprocess to keep in sync with it.
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/publish-mcp.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Aspire does not run or manage this process — it's a separate stdio subprocess your MCP
|
|
||||||
client spawns directly, so nothing rebuilds it automatically. Re-run the script (and
|
|
||||||
reconnect your MCP client) after pulling changes that touch `src/Novelly.Mcp`, or it keeps
|
|
||||||
serving whatever was published last, including against a stale auth contract.
|
|
||||||
|
|
||||||
Copy `.mcp.json.example` to `.mcp.json` (gitignored, since it carries your API key) and
|
Copy `.mcp.json.example` to `.mcp.json` (gitignored, since it carries your API key) and
|
||||||
fill in the key:
|
fill in the key:
|
||||||
@@ -213,10 +203,10 @@ fill in the key:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"novelly": {
|
"novelly": {
|
||||||
"command": "/absolute/path/to/mcp-server/Novelly.Mcp",
|
"type": "http",
|
||||||
"env": {
|
"url": "http://localhost:5080/mcp",
|
||||||
"NOVELLY_API_URL": "http://localhost:5080",
|
"headers": {
|
||||||
"NOVELLY_API_KEY": "<matches the API's Auth:ServiceApiKey user secret>"
|
"X-Novelly-Api-Key": "<matches the API's Auth:ServiceApiKey user secret>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,8 +215,12 @@ fill in the key:
|
|||||||
|
|
||||||
The API must be running, with `Auth:ServiceApiKey` set (e.g. via
|
The API must be running, with `Auth:ServiceApiKey` set (e.g. via
|
||||||
`dotnet user-secrets set Auth:ServiceApiKey <key> -p src/Novelly.Api`) to the same value
|
`dotnet user-secrets set Auth:ServiceApiKey <key> -p src/Novelly.Api`) to the same value
|
||||||
as `NOVELLY_API_KEY` above. If the API is not running, or the key is missing or mismatched,
|
as the `X-Novelly-Api-Key` header above. If the API is not running, or the key is missing or
|
||||||
the tools say so in a message the model can act on rather than failing opaquely.
|
mismatched, the request 401s.
|
||||||
|
|
||||||
|
Tool argument names are camelCase, matching the REST API and every other MCP argument name
|
||||||
|
this project has ever used. `create_novel` called over MCP is owned by the seeded service
|
||||||
|
user (an Admin), not whichever person is signed into the web app.
|
||||||
|
|
||||||
### Importing an existing outline
|
### Importing an existing outline
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -123,7 +123,7 @@
|
|||||||
|
|
||||||
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
|
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
|
||||||
<text x="53" y="14" fill="#fff">Coverage</text>
|
<text x="53" y="14" fill="#fff">Coverage</text>
|
||||||
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">65.3%</text><text class="" x="132.5" y="14">65.3%</text>
|
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">66.5%</text><text class="" x="132.5" y="14">66.5%</text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -8,6 +8,7 @@ services:
|
|||||||
|
|
||||||
api:
|
api:
|
||||||
image: ${API_IMAGE}:latest
|
image: ${API_IMAGE}:latest
|
||||||
|
container_name: novelly-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Novel: "Data Source=/data/novel.db"
|
ConnectionStrings__Novel: "Data Source=/data/novel.db"
|
||||||
@@ -15,10 +16,14 @@ services:
|
|||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
Agent__Model: claude-sonnet-5
|
Agent__Model: claude-sonnet-5
|
||||||
Agent__Effort: high
|
Agent__Effort: high
|
||||||
|
Imports__RootPath: /data/imports
|
||||||
|
Auth__ServiceApiKey: ${MCP_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/storage/apps/novelly/data:/data
|
- /mnt/storage/apps/novelly/data:/data
|
||||||
networks:
|
networks:
|
||||||
- novelly
|
- novelly
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-5080}:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/health || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -28,6 +33,7 @@ services:
|
|||||||
|
|
||||||
web:
|
web:
|
||||||
image: ${WEB_IMAGE}:latest
|
image: ${WEB_IMAGE}:latest
|
||||||
|
container_name: novelly-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Keyboard conventions
|
||||||
|
|
||||||
|
Novelly's web client is built to be driven entirely from the keyboard. New interactive
|
||||||
|
components should follow these rules so the app stays consistent as it grows.
|
||||||
|
|
||||||
|
## Escape cancels or closes — it never destroys already-saved work
|
||||||
|
|
||||||
|
In an editor that commits per field (a beat row, a chapter's title), Escape reverts only the
|
||||||
|
field you're currently in and then closes the editor. Fields you already tabbed past and
|
||||||
|
committed stay saved — Escape is honest about this, not a full undo. Anywhere a component *can*
|
||||||
|
offer a true "discard everything" cancel (a create form that hasn't saved anything yet), do
|
||||||
|
that instead.
|
||||||
|
|
||||||
|
## Enter commits a single-line field and advances
|
||||||
|
|
||||||
|
Pressing Enter in a single-line field is equivalent to Tab: it commits the field's value and
|
||||||
|
moves focus to the next field. Shift+Enter moves to the previous field. This is what
|
||||||
|
`AutoField` (`src/components/ui.tsx`) does by default — reuse it rather than hand-rolling a
|
||||||
|
text input's key handling.
|
||||||
|
|
||||||
|
## mod+Enter commits a multiline field or completes a record
|
||||||
|
|
||||||
|
A `<textarea>` needs plain Enter to insert a newline, so multiline fields commit on
|
||||||
|
`mod+Enter` (Cmd or Ctrl) instead. The same combo, handled at the row/form level, means "I'm
|
||||||
|
done with this record" — closing a beat row, submitting a question. This mirrors the app's
|
||||||
|
original convention in `AgentPanel.tsx` (`mod+Enter` sends a message).
|
||||||
|
|
||||||
|
## Bare single letters create the primary thing on the page
|
||||||
|
|
||||||
|
`n` is the default create-hotkey across the app (new character, new chapter, new location). A
|
||||||
|
page with a second creatable thing uses a mnemonic instead (`b` for beat, `q` for question, `a`
|
||||||
|
for arc stage). Register these with `useHotkey` from the component that owns the create action,
|
||||||
|
so the shortcut is scoped to that page/section and unregisters when it unmounts — never
|
||||||
|
register a bare letter globally.
|
||||||
|
|
||||||
|
## Creating something puts focus in its first editable field
|
||||||
|
|
||||||
|
A create action that leaves the user hunting for the thing they just made is a bug. Land focus
|
||||||
|
in the new item's first field (or, when a mutation's response id isn't the field's DOM node
|
||||||
|
yet, request focus for that id and let it land once the row/page actually renders — see the
|
||||||
|
`focusRequestId`/`onAutoFocused` pattern used for beats and arc stages).
|
||||||
|
|
||||||
|
## Chip inputs commit on Enter, comma, or blur
|
||||||
|
|
||||||
|
`TagEditor`, `LocationEditor`, `CharacterMultiSelect`, and `AliasEditor` all add their draft
|
||||||
|
value to the list on Enter, comma, or losing focus. Follow the same shape for any new
|
||||||
|
chip-style input.
|
||||||
|
|
||||||
|
## Destructive confirmations use `ConfirmModal`
|
||||||
|
|
||||||
|
Never use the native `confirm()`/`alert()` dialogs — they're not stylable, not consistent with
|
||||||
|
the rest of the app, and (depending on browser) can be genuinely awkward to dismiss from the
|
||||||
|
keyboard. Use `ConfirmModal` (`src/components/ConfirmModal.tsx`), which wraps `Modal` and gets
|
||||||
|
focus-trapping and Escape-to-close for free.
|
||||||
|
|
||||||
|
## The exception, not the rule: `allowInInputs`
|
||||||
|
|
||||||
|
`useHotkey` shortcuts don't fire while a text field is focused, unless registered with
|
||||||
|
`allowInInputs: true`. Reserve that for shortcuts that make sense mid-typing (`mod+Enter` to
|
||||||
|
submit, `Escape` to close) — never a bare letter.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Merge the MCP server into Novelly.Api as Streamable HTTP `/mcp`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Novelly has two duplicate tool surfaces that must be kept in sync by hand:
|
||||||
|
|
||||||
|
- **`src/Novelly.Mcp/`** — 45 tools as `[McpServerTool]` static methods, stdio-only, each calling the REST API back over HTTP via `NovelApiClient`. Built by no CI job, in no container, unknown to the AppHost. It only works if someone remembers to run `scripts/publish-mcp.sh` and re-point `.mcp.json` at the published binary. Zero tests.
|
||||||
|
- **`src/Novelly.Api/Agent/NovelAgentToolset.cs`** — 33 tools for the embedded web agent, calling application services directly in-process.
|
||||||
|
|
||||||
|
The 33 are a **strict subset** of the 45, matching name-for-name. The gap is pure capability loss for the web agent, not a design distinction.
|
||||||
|
|
||||||
|
Two problems follow. The MCP server is unreachable from anything but a local stdio subprocess, so the QA deploy can't serve it without shipping a binary around. And every new capability has to be written twice, in two idioms, with nothing enforcing that they agree.
|
||||||
|
|
||||||
|
**Outcome:** one tool registry, called directly by both surfaces. The API serves MCP over Streamable HTTP at `/mcp`, so any MCP client reaches it over the network with an API key and no binary to distribute. The stdio project is deleted. The web agent gains all 12 tools it was missing.
|
||||||
|
|
||||||
|
## Decisions taken
|
||||||
|
|
||||||
|
- **Delete `src/Novelly.Mcp` entirely.** No stdio proxy is kept.
|
||||||
|
- **The web agent gets all 45 tools**, including cross-novel `list_novels` and `create_novel`. Full parity, one list, no filtering.
|
||||||
|
- **camelCase argument names** everywhere. MCP specifies nothing about argument naming — `inputSchema` is plain JSON Schema — so this is a free choice, and camelCase matches .NET convention, the REST API's `JsonSerializerDefaults.Web` output, and the current MCP surface. Existing MCP clients keep working unchanged. The cost lands on `NovelAgentToolset`'s 33 hand-built schemas, which are today the only snake_case thing in the repo and must be rewritten.
|
||||||
|
- **Tool names stay snake_case** (`list_novels`, `get_novel_brief`) — that part *is* genuine MCP convention, and both surfaces already agree on it.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
A single registry — the existing `AgentTool` shape in `NovelAgentToolset`, extended to all 45 tools — with two thin adapters over it.
|
||||||
|
|
||||||
|
**MCP adapter.** Register the SDK's dynamic-tools handlers rather than 45 attribute-decorated methods:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddMcpServer(o => o.ServerInfo = new Implementation { Name = "novelly", Version = "1.0.0" })
|
||||||
|
.WithHttpTransport()
|
||||||
|
.WithListToolsHandler((request, ct) => ...)
|
||||||
|
.WithCallToolHandler((request, ct) => ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
Both handlers resolve `request.Services!.GetRequiredService<NovelAgentToolset>()` per request and delegate to a pure adapter class. This avoids fighting the SDK's schema inference (`McpServerToolCreateOptions` has no `InputSchema` property) and avoids hoisting the scoped, nine-dependency toolset into a static catalog, which `WithTools(IEnumerable<McpServerTool>)` would force.
|
||||||
|
|
||||||
|
**Agent adapter.** Unchanged — `NovelAgentService.SendMessageAsync` → `toolset.ExecuteAsync(name, novelId, input, ct)`, with `novelId` still ambient from the route and never shown to the model.
|
||||||
|
|
||||||
|
**Novel scoping, one entry / two shapes.** `AgentTool` and `AgentToolDefinition` each gain a trailing `bool RequiresNovelId = false`, so the 33 existing construction sites keep compiling. The MCP adapter injects a required `novelId` property into the advertised schema for those tools and extracts it at call time; the agent adapter supplies it from the route. `list_novels` / `create_novel` need no scope at all.
|
||||||
|
|
||||||
|
The 11 currently novel-scoped tools are identifiable mechanically — `grep -n 'async (novelId' src/Novelly.Api/Agent/NovelAgentToolset.cs`: `get_novel_brief`, `update_novel_brief`, `list_characters`, `create_character`, `list_tags`, `list_locations`, `list_chapters`, `create_chapter`, `get_character_beats`, `list_open_questions`, `raise_open_question`. Of the 12 new tools, `create_tag` and `create_location` are novel-scoped; the other ten are child-id-scoped or unscoped.
|
||||||
|
|
||||||
|
### Verified before planning
|
||||||
|
|
||||||
|
The design rests on SDK behaviour that build-and-test would not catch, so it was checked against a running probe app rather than inferred from docs:
|
||||||
|
|
||||||
|
- `WithListToolsHandler` / `WithCallToolHandler` exist in `ModelContextProtocol` 2.1.0; `Tool.InputSchema` is a settable `JsonElement` whose setter validates exactly what `JsonSchemaBuilder.Build()` already emits.
|
||||||
|
- **`capabilities.tools` *is* advertised** on `initialize` with handlers and no `ToolCollection` — this was the main open risk and it is closed.
|
||||||
|
- **`request.Services` is non-null and yields a fresh DI scope per request** (three calls returned three distinct scope ids). This is what makes the scoped `NovelDbContext` and `NovelAgentToolset` correct here. `HttpServerTransportOptions.Stateless` defaults to `true` and `PerSessionExecutionContext` to `false` in 2.1.0, so no options need restating.
|
||||||
|
- A hand-built `JsonElement` schema survives verbatim onto `tools/list` output, and `IsError` maps to `result.isError` rather than a JSON-RPC error object.
|
||||||
|
- `GET /mcp` returns 405 in stateless mode. Harmless; clients only POST.
|
||||||
|
|
||||||
|
## Chunks
|
||||||
|
|
||||||
|
Each builds, tests and commits independently.
|
||||||
|
|
||||||
|
### Chunk 0 — Fix the cross-novel conversation leak
|
||||||
|
|
||||||
|
Independent; do it first to keep it out of the main diff.
|
||||||
|
|
||||||
|
`NovelAgentService.FindConversationAsync(Guid conversationId, ...)` matches on id alone, so a conversation belonging to novel X can be continued under novel Y's route, after which every tool call runs against Y with X's transcript. Add an optional novel filter, passed from `SendMessageAsync` only — `GetConversationAsync`/`DeleteConversationAsync` are reached via `/api/conversations/{id}`, which has no novel in the route, and keep passing `null`. Log the miss at Warning with `{ConversationId}`/`{NovelId}`.
|
||||||
|
|
||||||
|
**Files:** `src/Novelly.Api/Agent/NovelAgentService.cs`, `tests/Novelly.Api.Tests/NovelAgentServiceTests.cs`
|
||||||
|
|
||||||
|
**Tests:** `Continuing_a_conversation_under_a_different_novel_is_rejected`, plus `Continuing_a_conversation_under_its_own_novel_still_works` as the guard against over-tightening.
|
||||||
|
|
||||||
|
### Chunk 1 — One registry, all 45 tools (no MCP wiring yet)
|
||||||
|
|
||||||
|
Delivers the parity decision on its own, verifiable through the existing web agent.
|
||||||
|
|
||||||
|
- `src/Novelly.Api/Agent/AgentContracts.cs` — add `RequiresNovelId` to `AgentToolDefinition`. Safe: `AnthropicAgentModelClient.ToSdkTool` maps `Name`/`Description`/`InputSchema` explicitly, so the flag never reaches the model.
|
||||||
|
- `src/Novelly.Api/Agent/NovelAgentToolset.cs` — add `RequiresNovelId` to `AgentTool`, set it on the 11 tools above, flow it into `Definitions`, and add the 12 new tools reusing the existing `OrNotFound` / `DeletedOrNotFound` / `ToolNotFound` idioms and the descriptions from the corresponding `src/Novelly.Mcp/Tools/*.cs` methods.
|
||||||
|
- **Rename the 33 existing schemas to camelCase** in the same file — both the `JsonSchemaBuilder` property keys and the matching `JsonInput` lookup strings, which must stay in lockstep (`.Str("character_id", …)` / `JsonInput.RequiredGuid(input, "character_id")` → `"characterId"`). Mechanical and contained to this one file, but it is the bulk of the chunk's diff and a mismatched pair fails silently as a missing argument rather than a compile error — so the per-tool tests below are what actually catch it. The 12 new tools are written camelCase from the start, matching the names their `src/Novelly.Mcp/Tools/*.cs` equivalents already used.
|
||||||
|
|
||||||
|
The 12 new tools and their existing service calls — no service-layer work is needed:
|
||||||
|
|
||||||
|
| tool | scope | service call |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_novels` | none | `novels.ListAsync` |
|
||||||
|
| `create_novel` | none | `novels.CreateAsync` |
|
||||||
|
| `get_character` | child | `characters.GetAsync` |
|
||||||
|
| `relate_characters` | child | `characters.AddRelationshipAsync` — note `CreateRelationshipRequest`'s parameter order differs from the old MCP method's |
|
||||||
|
| `set_arc_stage_beats` | child | `arcs.SetBeatsAsync` |
|
||||||
|
| `create_tag` | **novel** | `tags.CreateAsync` |
|
||||||
|
| `update_tag` / `delete_tag` | child | `tags.UpdateAsync` / `DeleteAsync` |
|
||||||
|
| `create_location` | **novel** | `locations.CreateAsync` |
|
||||||
|
| `update_location` / `delete_location` | child | `locations.UpdateAsync` / `DeleteAsync` |
|
||||||
|
| `update_open_question` | child | `questions.UpdateAsync` |
|
||||||
|
|
||||||
|
Guid-list arguments follow the existing `JsonInput.Guids` idiom used by `reorder_beats`.
|
||||||
|
|
||||||
|
**Tests:** new `tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs`, driving `ExecuteAsync` directly in the style of `ImportAgentToolsetTests.cs` (`ServiceTestFixture` already wires every service the toolset needs). BDD names, one per new capability — e.g. `Relating_two_characters_shows_the_pair_on_both_dossiers`, `Deleting_a_tag_leaves_the_characters_that_carried_it_alone`, `Updating_an_open_question_can_detach_it_from_its_chapter`. Two structural tests carry the most weight:
|
||||||
|
|
||||||
|
- `The_toolset_offers_every_tool_the_stdio_server_offered` — assert the 45 names against a hard-coded array. This is the anti-drift test.
|
||||||
|
- Assert no `RequiresNovelId` tool's schema already declares `novelId`, since the MCP adapter injects it and a duplicate would be silent.
|
||||||
|
|
||||||
|
**Runtime verify:** AppHost up, open a novel's agent panel, ask it to create a tag and list tags; confirm in the Aspire trace.
|
||||||
|
|
||||||
|
### Chunk 2 — Serve the registry at `/mcp`
|
||||||
|
|
||||||
|
- `src/Novelly.Api/Novelly.Api.csproj` — add `ModelContextProtocol.AspNetCore` 2.1.0 (brings Core transitively; don't reference it directly). **Not in the local NuGet cache — first restore needs network.** Pin 2.1.0 to match the verified surface.
|
||||||
|
- New feature folder `src/Novelly.Api/Mcp/`:
|
||||||
|
- `NovelMcpTools.cs` — the adapter, as pure static methods: `Describe(definitions)` maps to `Tool` records, injecting `novelId` where `RequiresNovelId`; `CallAsync(toolset, parameters, ct)` serializes arguments to a `JsonElement`, extracts `novelId` when required (`Guid.Empty` otherwise), calls `ExecuteAsync`, and maps `AgentToolResult` → `CallToolResult`. Catch the `ArgumentException` from a missing/malformed `novelId` and return it as `IsError` rather than letting it escape as a JSON-RPC error. **Log `{Tool}` and `{NovelId}` only — never the arguments, which carry prose (`what_happened`, `synopsis`, `notes`).**
|
||||||
|
- `McpEndpoints.cs` — `MapNovelMcp()` calling `app.MapMcp("/mcp")`, matching the repo's `Map*Endpoints` convention.
|
||||||
|
- `src/Novelly.Api/Common/NovellyServiceRegistration.cs` — the `AddMcpServer(...)` registration shown above.
|
||||||
|
- `src/Novelly.Api/Program.cs` — `.MapNovelMcp()` after `UseAuthentication()`/`UseAuthorization()`.
|
||||||
|
|
||||||
|
**Auth — the trap.** Do **not** chain `.RequireAuthorization()` onto `MapMcp`. The parameterless overload applies the *default* policy, which authenticates `IdentityConstants.ApplicationScheme` only and would reject the API key. The fallback policy already registered in `NovellyServiceRegistration` lists both that scheme *and* `ServiceApiKeyAuthenticationHandler.SchemeName`, and applies to any endpoint carrying no authorization metadata — which `MapMcp` adds none of. `/mcp` inherits the right protection by doing nothing. If explicitness is wanted, register a named policy listing both schemes; never the parameterless call.
|
||||||
|
|
||||||
|
External clients send `X-Novelly-Api-Key: <Auth:ServiceApiKey>`, resolving to `ServiceUser` (Admin), which sees every novel. If `Auth:ServiceApiKey` is unset the service user is never seeded and every call 401s.
|
||||||
|
|
||||||
|
CORS needs no change — the origin-restricted default policy is irrelevant to non-browser clients, and stateless mode exposes no `Mcp-Session-Id` header to read.
|
||||||
|
|
||||||
|
**Tests:** new `tests/Novelly.Api.Tests/NovelMcpToolsTests.cs`, against the pure adapter methods — no live session, no `WebApplicationFactory`. Cover: all 45 advertised with unique names; novel-scoped tools declare a required `novelId` and child-id tools don't; `list_novels` needs none; every advertised schema is a valid `type: object` (what `Tool.InputSchema`'s setter enforces, worth asserting before the SDK throws at startup); a missing `novelId` and a not-found id both come back as `IsError` results.
|
||||||
|
|
||||||
|
**Deliberately not adding a `WebApplicationFactory` harness.** None exists in the repo; adding one means an MVC.Testing reference, overriding the connection string, working around `Program.cs`'s boot-time `MigrateAsync` + `Environment.Exit(1)`, seeding the key, and parsing SSE. Its main payoff — proving the SDK wires up — is delivered more honestly by the curl walkthrough below, which exercises real Kestrel including auth, Serilog and the exception handler. Worth a separate chunk later if a regression harness is wanted.
|
||||||
|
|
||||||
|
### Chunk 3 — Delete the stdio server
|
||||||
|
|
||||||
|
Only after Chunk 2 is verified, so there's never a window with no MCP surface.
|
||||||
|
|
||||||
|
Remove `src/Novelly.Mcp/`, `scripts/publish-mcp.sh`, the `mcp-server/` publish output (gitignored; working-tree cleanup only), and the project line in `Novelly.slnx`. Verified as *not* referencing it: `scripts/ci/build.sh` (publishes the API by path), `prepush.sh`, `test.sh`, both Dockerfiles, the AppHost.
|
||||||
|
|
||||||
|
Docs to update:
|
||||||
|
- `README.md` — fold the `Novelly.Mcp` stack-table row into the API's; rewrite "The MCP server" section (same 45 tools, now in-process at `POST /mcp`, no build step, `X-Novelly-Api-Key` auth). Note that MCP argument names are unchanged (camelCase), so existing clients need no edits, and that `create_novel` is owned by the signed-in user over the web agent but by the service user over MCP.
|
||||||
|
- `.mcp.json` / `.mcp.json.example` — switch to `type: "http"`, `url: http://localhost:5080/mcp`, with the key in `headers`.
|
||||||
|
- `.claude/agents/outline-importer.md` — drop the published-binary requirement. Its `tools:` frontmatter is **already stale** (`mcp__novelly__list_projects`, `get_project_brief`, `create_project` exist in neither surface); fix to real names while here.
|
||||||
|
- `CLAUDE.md` — the Structure list still calls `src/Novelly.Mcp/` the "MCP stdio server" and Verifying still says "drive over stdio JSON-RPC". Propose these edits rather than slipping them in; CLAUDE.md is user-owned.
|
||||||
|
|
||||||
|
Leave `docs/plans/api/users_and_roles_plan.md` alone — historical.
|
||||||
|
|
||||||
|
**Verify:** `dotnet build Novelly.slnx` and `./scripts/ci/prepush.sh` both pass.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Streamable HTTP needs `Accept: application/json, text/event-stream` and replies SSE-framed, so pipe through `sed -n 's/^data: //p'`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet user-secrets set Auth:ServiceApiKey devkey -p src/Novelly.Api
|
||||||
|
ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api
|
||||||
|
|
||||||
|
MCP=http://localhost:5080/mcp
|
||||||
|
H=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "X-Novelly-Api-Key: devkey")
|
||||||
|
|
||||||
|
# handshake — expect capabilities.tools present
|
||||||
|
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' | sed -n 's/^data: //p' | jq .
|
||||||
|
curl -sS "${H[@]}" -o /dev/null -w '%{http_code}\n' "$MCP" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
||||||
|
|
||||||
|
# expect exactly 45
|
||||||
|
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | sed -n 's/^data: //p' | jq '.result.tools | length'
|
||||||
|
|
||||||
|
# novelId injection: present on list_tags, absent on delete_tag and list_novels
|
||||||
|
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}' | sed -n 's/^data: //p' \
|
||||||
|
| jq '.result.tools[] | select(.name=="list_tags" or .name=="delete_tag" or .name=="list_novels") | {name, props:(.inputSchema.properties|keys), required:.inputSchema.required}'
|
||||||
|
|
||||||
|
# unscoped call, then a novel-scoped one (proves NovelUserContext resolved the service user in-handler)
|
||||||
|
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_novels","arguments":{}}}' | sed -n 's/^data: //p' | jq .
|
||||||
|
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_novel_brief","arguments":{"novelId":"<id from above>"}}}' | sed -n 's/^data: //p' | jq .
|
||||||
|
|
||||||
|
# error mapping — expect result.isError true, not a JSON-RPC error
|
||||||
|
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_tags","arguments":{}}}' | sed -n 's/^data: //p' | jq .
|
||||||
|
|
||||||
|
# auth — expect 401 with no key
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}\n' -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
|
||||||
|
"$MCP" -d '{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}'
|
||||||
|
|
||||||
|
# one write, end to end
|
||||||
|
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"create_tag","arguments":{"novelId":"<id>","name":"Salt","color":"#9a4a2f"}}}' | sed -n 's/^data: //p' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reconnect a real client: rewrite `.mcp.json` to the `type: "http"` form and run `/mcp` in Claude Code to confirm 45 tools. Finally confirm the web agent still drives the same registry (Aspire up, agent panel, exercise one of the 12 new tools) and that the dashboard shows `/mcp` requests with no prose in the log lines.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`ModelContextProtocol.AspNetCore` is not cached locally.** First restore needs network.
|
||||||
|
- **The web agent's argument names change** (snake_case → camelCase) across all 33 existing tools. Nothing external consumes those schemas — they are built fresh per request and handed to the model each turn — so there is no compatibility surface, but a `JsonSchemaBuilder` key left out of step with its `JsonInput` lookup fails silently as a missing argument rather than a compile error. External MCP clients are unaffected: their argument names were already camelCase.
|
||||||
|
- **`create_novel` ownership differs by surface** — signed-in user vs service user. Not a bug, but surprising; document it.
|
||||||
|
- **Not fixed here:** child-id-scoped tools carry no novel context in *either* surface, so a caller holding a foreign beat/tag/question id can reach across novels, subject only to `NovelAccessService`. Pre-existing for 20+ tools and unchanged by this work. Worth a follow-up.
|
||||||
|
- **QA deploy:** once merged, `/mcp` rides the existing API container and its already-exposed port. `MCP_API_KEY` must be set in Gitea for `Auth__ServiceApiKey`, or every MCP call 401s.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Frontend Modernization — Output
|
||||||
|
|
||||||
|
Implemented per `docs/plans/web/frontend-modernization_plan.md`, all 5 chunks.
|
||||||
|
|
||||||
|
## Chunk 1 — Design tokens + primitives
|
||||||
|
|
||||||
|
- `src/index.css`: full token rewrite. Dark-first palette (`--canvas`, `--surface`, `--surface-sunken`, `--ink`, `--ink-muted`, `--line`), violet `--accent` default, light-mode override via `prefers-color-scheme` + `data-theme`.
|
||||||
|
- Five-stage color ramp `--stage-1..5` (violet → blue → coral → teal → gold), shared by `NovelPhase` and `DraftStatus` via `src/api/stage.ts` (`novelPhaseColor`, `draftStatusColor`) — both are 5-step progressions, one hue system backs both.
|
||||||
|
- Fonts self-hosted via `@fontsource-variable/*` (no CDN dep): Fraunces (display), Inter (UI), Source Serif 4 (prose/markdown), JetBrains Mono (utility). Imported in `src/main.tsx`.
|
||||||
|
- `src/components/ui.tsx` primitives (`.card`, `.btn`, `.input`, `StatusBadge`, etc.) rebuilt on the new tokens.
|
||||||
|
|
||||||
|
## Chunk 2 — Sidebar shell
|
||||||
|
|
||||||
|
- `src/pages/NovelLayout.tsx` rebuilt: left sidebar (wordmark, novel title, phase pill, icon nav) replaces the old header + horizontal tab bar. Kills the old back-link-next-to-title layout — top bar is now just a breadcrumb.
|
||||||
|
- New `src/components/icons.tsx` — small hand-written inline SVG icon set (no icon library dependency).
|
||||||
|
- **Signature element**: novel's `phase` sets `--accent`/`--accent-soft` for the whole layout, scoped via inline style on the layout root. Nav active state, buttons, focus rings, phase pill all recolor together when phase changes.
|
||||||
|
- Bug fixed during build: breadcrumb section-matching used a suffix `startsWith` check that broke on exact segment matches (`chapters` vs `chapters/`) — replaced with explicit segment split/compare.
|
||||||
|
|
||||||
|
## Chunk 3 — Dashboard rebuild
|
||||||
|
|
||||||
|
- `src/pages/DashboardPage.tsx`: quick-actions row now leads the page — **New chapter** (creates + jumps into the editor), **New character** (reuses the add-character modal, now exported from `CharactersPage.tsx`), and **Continue writing** (jumps to the most-recently-updated chapter, or **View chapters** if nothing's drafted).
|
||||||
|
- Brainstorming phase gets its own pair above the notes field: **Add a character** / **Move to outlining**.
|
||||||
|
- Renamed `OutliningDashboard` → `WorkDashboard` (it covers Outlining/Writing/Editing/Complete, not just Outlining — old name was misleading).
|
||||||
|
- History content (activity graph, recent chapters/characters, tag cloud) unchanged, just repositioned under the new hero row.
|
||||||
|
|
||||||
|
## Chunk 4 — Global agent panel
|
||||||
|
|
||||||
|
- `/agent` route and `pages/AgentPage.tsx` retired.
|
||||||
|
- New `src/components/AgentPanel.tsx`: fixed slide-out drawer mounted in `NovelLayout` (shell level), reachable from every page in a novel via the sidebar "Agent" toggle or `g a`. Non-modal — background stays interactive.
|
||||||
|
- **Context-aware**: panel shows "Talking about {X}" — resolves to the specific chapter/character title on detail pages, falls back to section name elsewhere. Each outgoing message gets a `Context: {label}` line prepended (server has no route awareness, so this is how the agent learns what page you're on); stripped back out and shown as a small "re: …" tag on render rather than raw text in the transcript.
|
||||||
|
- Compacted the old two-pane (sidebar list + chat) layout into a single column with a conversation-switcher dropdown — panel width doesn't fit a full list rail.
|
||||||
|
- No backend changes — works within the existing `SendAgentMessageRequest` shape.
|
||||||
|
|
||||||
|
## Chunk 5 — Polish
|
||||||
|
|
||||||
|
- Global `:focus-visible` ring via `box-shadow` (not `outline`, to avoid clobbering `TagColorPicker`'s outline-based selection indicator or `.input`'s own focus ring). Ring color follows the phase accent.
|
||||||
|
- Global `prefers-reduced-motion: reduce` override (`!important` on `animation-duration`/`transition-duration`/`scroll-behavior`) — neutralizes the agent panel's slide transition too, since author `!important` beats a normal-priority inline style in the cascade.
|
||||||
|
- Fixed two leftover hardcoded `#9a4a2f` (old terracotta accent) defaults in `TagColorPicker.tsx` and `TagsPage.tsx` → new violet `#7c5cff`.
|
||||||
|
- Audited remaining pages (Settings, Locations, Tags, Characters, Chapters) — all inherit cleanly from the chunk-1 primitives already, no stale styling found.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Every chunk built clean (`npm run build`) and was clicked through live in Chrome against a local API + SQLite instance — login/signup, novel creation, phase switching (confirmed accent recolor live: violet → blue → coral), chapter/character creation flows, agent panel open/close/context-swap across navigation, keyboard focus ring.
|
||||||
|
|
||||||
|
## Deferred / not done
|
||||||
|
|
||||||
|
- Sidebar collapse toggle — mentioned in the original plan's layout description ("persistent, icon+label, collapsible") but never implemented; flagged as deferred in chunk 2 and again in chunk 5. Would need its own pass (collapsed-width icon rail, persisted preference).
|
||||||
|
- No backend/API changes anywhere in this arc — all five chunks were frontend-only.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Frontend Modernization Plan
|
||||||
|
|
||||||
|
## Design direction
|
||||||
|
|
||||||
|
Drop warm-paper/serif "manuscript" look — reads dated, low-contrast, single dull accent. New identity: **phase-driven color**. Novelly already models a novel's lifecycle as phases (`Brainstorming → Outlining → Drafting → Revising → Final`, see `novelPhases`, `StatusBadge` tones). Make that real data drive the whole app's mood instead of hiding in a badge — the active novel's phase sets an accent hue across nav, buttons, focus rings, charts. Writer sees at a glance "I'm in draft mode" vs "polishing." Distinctive, grounded in the product's own model, not decoration.
|
||||||
|
|
||||||
|
### Tokens
|
||||||
|
|
||||||
|
Color (base neutrals, dark-first):
|
||||||
|
- `--ink: #14121a` / `--ink-muted: #8b859a`
|
||||||
|
- `--surface: #1b1825` (panel/card) / `--surface-sunken: #100e17`
|
||||||
|
- `--canvas: #0c0a12` (app background)
|
||||||
|
- `--line: #2c2838`
|
||||||
|
- Light mode mirrors with `--canvas:#f7f6fb`, `--surface:#ffffff`, `--ink:#14121a`
|
||||||
|
|
||||||
|
Phase accents (used for `--accent` + `--accent-soft`, swapped by `novel.phase`):
|
||||||
|
- Brainstorming — `#7c5cff` violet
|
||||||
|
- Outlining — `#2f8fe0` blue
|
||||||
|
- Drafting — `#ff7a45` coral
|
||||||
|
- Revising — `#14b88a` teal
|
||||||
|
- Final — `#d9a404` gold
|
||||||
|
|
||||||
|
Type:
|
||||||
|
- Display (headlines, dashboard hero, page titles): **Fraunces** — variable serif w/ real character, used large/sparingly
|
||||||
|
- UI (nav, buttons, body chrome): **Inter**
|
||||||
|
- Prose editing (chapter/beat text, agent transcript): keep a serif for long-form reading — **Source Serif 4** replaces Iowan/Palatino (renders consistently, not Mac-only)
|
||||||
|
- Utility/data (counts, timestamps, mono bits): **JetBrains Mono**
|
||||||
|
|
||||||
|
Layout: left sidebar nav (persistent, icon+label, collapsible), agent as a right-docked slide-out panel triggered from anywhere (sidebar icon, always visible), main content full-bleed under a slim top bar (breadcrumb + phase pill + user menu — no more "← Novels" link floating left of the title).
|
||||||
|
|
||||||
|
Signature element: the phase-accent system itself — nav active states, primary buttons, focus rings, and the dashboard's activity graph all recolor together when phase changes. Nothing else in the app competes for boldness; everything else stays a disciplined dark neutral.
|
||||||
|
|
||||||
|
## Chunks (each independently buildable/committable)
|
||||||
|
|
||||||
|
1. **Design tokens + primitives** — rewrite `index.css` theme (colors, fonts incl. `@font-face`/Google Fonts imports, spacing), update `ui.tsx` primitives (`btn`, `card`, `input`, `StatusBadge`) to new tokens. No layout changes yet — existing pages just reskin. Fastest way to see the new palette/type everywhere at once.
|
||||||
|
2. **App shell: sidebar nav** — replace `NovelLayout`'s header+tab-bar with left sidebar (novel switcher, section nav, phase pill), slim top bar. Fixes the back-link-left-of-title complaint structurally. Agent gets a nav icon but no panel yet (still routes to `/agent` page).
|
||||||
|
3. **Dashboard rebuild** — make it the true home: recent activity + work history (already there) alongside prominent "start new work" actions (new chapter, new character, continue last chapter) above the fold. This is the biggest content/layout change, isolated to one page.
|
||||||
|
4. **Global agent panel** — extract `AgentPage`'s chat UI into a slide-out panel mounted at the app shell level (outside `<Outlet>`), triggered from the sidebar on any route, passes current route/entity as context. Retire the standalone `/agent` route once panel covers it.
|
||||||
|
5. **Polish pass** — motion (panel slide, nav active-state transitions, dashboard load-in), empty states, focus-visible/reduced-motion audit, remaining pages (Characters/Chapters/Tags/Locations/Settings) get spacing/type touch-ups to match new primitives from chunk 1.
|
||||||
|
|
||||||
|
Suggest reviewing after each chunk before starting the next — chunk 2 and 4 both touch navigation/shell so seeing 1–2 landed first will make it obvious if the sidebar direction is right before the agent panel builds on top of it.
|
||||||
@@ -13,6 +13,8 @@ registry_login
|
|||||||
export API_IMAGE WEB_IMAGE
|
export API_IMAGE WEB_IMAGE
|
||||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
|
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
|
||||||
export WEB_PORT="${WEB_PORT:-6173}"
|
export WEB_PORT="${WEB_PORT:-6173}"
|
||||||
|
export API_PORT="${API_PORT:-5080}"
|
||||||
|
export MCP_API_KEY="${MCP_API_KEY:-}"
|
||||||
|
|
||||||
COMPOSE="docker compose -f deploy/qa/docker-compose.qa.yml"
|
COMPOSE="docker compose -f deploy/qa/docker-compose.qa.yml"
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Rebuilds the standalone Novelly.Mcp binary that Claude Code (or Claude Desktop) spawns
|
|
||||||
# per .mcp.json. Aspire does not run or manage this process, so nothing else rebuilds it —
|
|
||||||
# run this after pulling changes that touch src/Novelly.Mcp, or the MCP server silently
|
|
||||||
# keeps serving whatever was published last.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")/.." && source ./scripts/ci/lib.sh
|
|
||||||
cd "$CI_ROOT"
|
|
||||||
|
|
||||||
ensure_dotnet
|
|
||||||
|
|
||||||
log "Publishing Novelly.Mcp to ./mcp-server"
|
|
||||||
dotnet publish src/Novelly.Mcp -c Release -o ./mcp-server
|
|
||||||
|
|
||||||
log "Done. Reconnect the MCP server (e.g. /mcp in Claude Code) to pick up the new build."
|
|
||||||
@@ -21,7 +21,8 @@ public enum ActivityAction
|
|||||||
{
|
{
|
||||||
Created,
|
Created,
|
||||||
Updated,
|
Updated,
|
||||||
Deleted
|
Deleted,
|
||||||
|
Restored
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ActivityEvent
|
public class ActivityEvent
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Novelly.Api.Agent;
|
namespace Novelly.Api.Agent;
|
||||||
|
|
||||||
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema);
|
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema, bool RequiresNovelId = false);
|
||||||
|
|
||||||
public abstract record AgentContentBlock;
|
public abstract record AgentContentBlock;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public class NovelAgentService(
|
|||||||
|
|
||||||
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
|
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
|
||||||
|
|
||||||
return await FindConversationAsync(conversationId, ct);
|
return await FindConversationAsync(conversationId, null, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||||
@@ -52,7 +52,7 @@ public class NovelAgentService(
|
|||||||
|
|
||||||
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
|
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
|
||||||
|
|
||||||
var conversation = await FindConversationAsync(conversationId, ct);
|
var conversation = await FindConversationAsync(conversationId, null, ct);
|
||||||
if (conversation is null)
|
if (conversation is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -81,7 +81,7 @@ public class NovelAgentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var conversation = request.ConversationId is { } id
|
var conversation = request.ConversationId is { } id
|
||||||
? await FindConversationAsync(id, ct)
|
? await FindConversationAsync(id, novelId, ct)
|
||||||
: StartConversation(novelId, request.Message);
|
: StartConversation(novelId, request.Message);
|
||||||
|
|
||||||
if (conversation is null) return null;
|
if (conversation is null) return null;
|
||||||
@@ -181,7 +181,7 @@ public class NovelAgentService(
|
|||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, CancellationToken ct)
|
private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, Guid? novelId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
logger.LogDebug("Finding agent conversation {ConversationId}", conversationId);
|
logger.LogDebug("Finding agent conversation {ConversationId}", conversationId);
|
||||||
|
|
||||||
@@ -195,6 +195,14 @@ public class NovelAgentService(
|
|||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (novelId is { } expectedNovelId && conversation.NovelId != expectedNovelId)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"AgentConversation {ConversationId} belongs to novel {ActualNovelId}, not requested novel {NovelId}",
|
||||||
|
conversationId, conversation.NovelId, expectedNovelId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogDebug("Found agent conversation {ConversationId}", conversationId);
|
logger.LogDebug("Found agent conversation {ConversationId}", conversationId);
|
||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public record AgentTool(
|
|||||||
string Name,
|
string Name,
|
||||||
string Description,
|
string Description,
|
||||||
JsonElement InputSchema,
|
JsonElement InputSchema,
|
||||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler,
|
||||||
|
bool RequiresNovelId = false);
|
||||||
|
|
||||||
public class NovelAgentToolset(
|
public class NovelAgentToolset(
|
||||||
NovelService novels,
|
NovelService novels,
|
||||||
@@ -46,7 +47,7 @@ public class NovelAgentToolset(
|
|||||||
private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
||||||
|
|
||||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema, t.RequiresNovelId))];
|
||||||
|
|
||||||
public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
|
public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -97,12 +98,45 @@ public class NovelAgentToolset(
|
|||||||
|
|
||||||
private IEnumerable<AgentTool> Build()
|
private IEnumerable<AgentTool> Build()
|
||||||
{
|
{
|
||||||
|
yield return new AgentTool(
|
||||||
|
"list_novels",
|
||||||
|
"List every novel, with counts of characters, chapters and drafted words. "
|
||||||
|
+ "Start here to find the novel id everything else needs.",
|
||||||
|
new JsonSchemaBuilder().Build(),
|
||||||
|
async (_, _, ct) => await novels.ListAsync(ct));
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"create_novel",
|
||||||
|
"Create a new novel.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("title", "Working title.", required: true)
|
||||||
|
.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("targetWordCount", "Target manuscript length in words.")
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var novel = await novels.CreateAsync(new CreateNovelRequest(
|
||||||
|
JsonInput.RequiredString(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, "targetWordCount")), ct);
|
||||||
|
return novel.ToResponse(null);
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"get_novel_brief",
|
"get_novel_brief",
|
||||||
"Read the novel's title, logline, synopsis, genre, notes and word-count target. "
|
"Read the novel's title, logline, synopsis, genre, notes and word-count target. "
|
||||||
+ "Call this first in a conversation to ground yourself in what the book is.",
|
+ "Call this first in a conversation to ground yourself in what the book is.",
|
||||||
new JsonSchemaBuilder().Build(),
|
new JsonSchemaBuilder().Build(),
|
||||||
async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId));
|
async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"update_novel_brief",
|
"update_novel_brief",
|
||||||
@@ -115,7 +149,7 @@ public class NovelAgentToolset(
|
|||||||
.Str("logline", "One-sentence pitch.")
|
.Str("logline", "One-sentence pitch.")
|
||||||
.Str("synopsis", "Paragraph-length summary of the whole book.")
|
.Str("synopsis", "Paragraph-length summary of the whole book.")
|
||||||
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
||||||
.Int("target_word_count", "Target manuscript length in words.")
|
.Int("targetWordCount", "Target manuscript length in words.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
||||||
JsonInput.String(input, "title"),
|
JsonInput.String(input, "title"),
|
||||||
@@ -124,13 +158,27 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.String(input, "logline"),
|
JsonInput.String(input, "logline"),
|
||||||
JsonInput.String(input, "synopsis"),
|
JsonInput.String(input, "synopsis"),
|
||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId));
|
JsonInput.Int(input, "targetWordCount")), ct), p => p.ToResponse(null), "Novel", novelId),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"list_characters",
|
"list_characters",
|
||||||
"List every character in the novel with their full dossiers.",
|
"List every character in the novel with their full dossiers.",
|
||||||
new JsonSchemaBuilder().Build(),
|
new JsonSchemaBuilder().Build(),
|
||||||
async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()));
|
async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"get_character",
|
||||||
|
"Read one character's dossier.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("characterId", "Id of the character to read.", required: true)
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
|
return await OrNotFound(characters.GetAsync(characterId, ct), c => c.ToResponse(), "Character", characterId);
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"create_character",
|
"create_character",
|
||||||
@@ -152,17 +200,18 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.String(input, "voice"),
|
JsonInput.String(input, "voice"),
|
||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Strings(input, "tags"),
|
JsonInput.Strings(input, "tags"),
|
||||||
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId));
|
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"update_character",
|
"update_character",
|
||||||
"Revise an existing character dossier. Only the fields you supply change.",
|
"Revise an existing character dossier. Only the fields you supply change.",
|
||||||
CharacterSchema(includeName: true, nameRequired: false)
|
CharacterSchema(includeName: true, nameRequired: false)
|
||||||
.Str("character_id", "Id of the character to update.", required: true)
|
.Str("characterId", "Id of the character to update.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
return await OrNotFound(characters.UpdateAsync(
|
return await OrNotFound(characters.UpdateAsync(
|
||||||
characterId,
|
characterId,
|
||||||
new UpdateCharacterRequest(
|
new UpdateCharacterRequest(
|
||||||
@@ -183,25 +232,49 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId);
|
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"relate_characters",
|
||||||
|
"Record a relationship between two characters in the same novel. Creates both directions "
|
||||||
|
+ "at once — characterId's side and relatedCharacterId's side — so the pair always shows up "
|
||||||
|
+ "on both dossiers.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("characterId", "Id of the character the relationship belongs to.", required: true)
|
||||||
|
.Str("relatedCharacterId", "Id of the character they are related to.", required: true)
|
||||||
|
.Str("relationshipType", "How characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.", required: true)
|
||||||
|
.Str("reciprocalRelationshipType", "How relatedCharacterId is related back to characterId, if different. Defaults to relationshipType when the relation is symmetric, like 'rival'.")
|
||||||
|
.Str("description", "What the relationship is like, and where it is headed.")
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
|
return await OrNotFound(characters.AddRelationshipAsync(
|
||||||
|
characterId,
|
||||||
|
new CreateRelationshipRequest(
|
||||||
|
JsonInput.RequiredGuid(input, "relatedCharacterId"),
|
||||||
|
JsonInput.RequiredString(input, "relationshipType"),
|
||||||
|
JsonInput.String(input, "description"),
|
||||||
|
JsonInput.String(input, "reciprocalRelationshipType")), ct), c => c.ToResponse(), "Character", characterId);
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"link_character_identity",
|
"link_character_identity",
|
||||||
"Record that a character is really another character — e.g. one introduced under one name "
|
"Record that a character is really another character — e.g. one introduced under one name "
|
||||||
+ "who is later revealed to be a character already in the novel under another name. Both "
|
+ "who is later revealed to be a character already in the novel under another name. Both "
|
||||||
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
|
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("character_id", "Id of the character being revealed as someone else.", required: true)
|
.Str("characterId", "Id of the character being revealed as someone else.", required: true)
|
||||||
.Str("same_character_as_id", "Id of the character this one really is.", required: true)
|
.Str("sameCharacterAsId", "Id of the character this one really is.", required: true)
|
||||||
.Str("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.")
|
.Str("revealedInChapterId", "Id of the chapter where the reveal happens, if any.")
|
||||||
.Str("note", "Context on the reveal, e.g. how and why the disguise held.")
|
.Str("note", "Context on the reveal, e.g. how and why the disguise held.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
return await OrNotFound(characters.LinkIdentityAsync(
|
return await OrNotFound(characters.LinkIdentityAsync(
|
||||||
characterId,
|
characterId,
|
||||||
new LinkCharacterIdentityRequest(
|
new LinkCharacterIdentityRequest(
|
||||||
JsonInput.RequiredGuid(input, "same_character_as_id"),
|
JsonInput.RequiredGuid(input, "sameCharacterAsId"),
|
||||||
JsonInput.Guid(input, "revealed_in_chapter_id"),
|
JsonInput.Guid(input, "revealedInChapterId"),
|
||||||
JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId);
|
JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,11 +282,11 @@ public class NovelAgentToolset(
|
|||||||
"unlink_character_identity",
|
"unlink_character_identity",
|
||||||
"Remove a character's identity link, restoring it to its own separate identity.",
|
"Remove a character's identity link, restoring it to its own separate identity.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("character_id", "Id of the character to unlink.", required: true)
|
.Str("characterId", "Id of the character to unlink.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId);
|
return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,29 +295,29 @@ public class NovelAgentToolset(
|
|||||||
"Read a chapter's outline: its summary paragraph and its beat table, in order. "
|
"Read a chapter's outline: its summary paragraph and its beat table, in order. "
|
||||||
+ "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
|
+ "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
|
.Str("chapterId", "Id of the chapter whose outline to read.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)).Select(b => b.ToResponse()));
|
async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapterId"), ct)).Select(b => b.ToResponse()));
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"create_beat",
|
"create_beat",
|
||||||
"Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
|
"Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
|
||||||
+ "handle, not a sentence; the detail belongs in what_happened and whats_next.",
|
+ "handle, not a sentence; the detail belongs in whatHappened and whatsNext.",
|
||||||
BeatSchema()
|
BeatSchema()
|
||||||
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
|
.Str("chapterId", "Id of the chapter the beat belongs to.", required: true)
|
||||||
.Str("title", "Three to five words naming the beat.", required: true)
|
.Str("title", "Three to five words naming the beat.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
|
||||||
return await OrNotFound(beats.CreateAsync(
|
return await OrNotFound(beats.CreateAsync(
|
||||||
chapterId,
|
chapterId,
|
||||||
new CreateBeatRequest(
|
new CreateBeatRequest(
|
||||||
JsonInput.RequiredString(input, "title"),
|
JsonInput.RequiredString(input, "title"),
|
||||||
JsonInput.Int(input, "sort_order"),
|
JsonInput.Int(input, "sortOrder"),
|
||||||
JsonInput.Guids(input, "character_ids"),
|
JsonInput.Guids(input, "characterIds"),
|
||||||
JsonInput.String(input, "what_happened"),
|
JsonInput.String(input, "whatHappened"),
|
||||||
JsonInput.String(input, "whats_next"),
|
JsonInput.String(input, "whatsNext"),
|
||||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
|
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,20 +326,20 @@ public class NovelAgentToolset(
|
|||||||
"Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
|
"Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
|
||||||
+ "the beat's tags outright, so include the ones you want to keep.",
|
+ "the beat's tags outright, so include the ones you want to keep.",
|
||||||
BeatSchema()
|
BeatSchema()
|
||||||
.Str("beat_id", "Id of the beat to update.", required: true)
|
.Str("beatId", "Id of the beat to update.", required: true)
|
||||||
.Str("title", "Three to five words naming the beat.")
|
.Str("title", "Three to five words naming the beat.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var beatId = JsonInput.RequiredGuid(input, "beat_id");
|
var beatId = JsonInput.RequiredGuid(input, "beatId");
|
||||||
return await OrNotFound(beats.UpdateAsync(
|
return await OrNotFound(beats.UpdateAsync(
|
||||||
beatId,
|
beatId,
|
||||||
new UpdateBeatRequest(
|
new UpdateBeatRequest(
|
||||||
JsonInput.String(input, "title"),
|
JsonInput.String(input, "title"),
|
||||||
JsonInput.Int(input, "sort_order"),
|
JsonInput.Int(input, "sortOrder"),
|
||||||
JsonInput.Guids(input, "character_ids"),
|
JsonInput.Guids(input, "characterIds"),
|
||||||
JsonInput.String(input, "what_happened"),
|
JsonInput.String(input, "whatHappened"),
|
||||||
JsonInput.String(input, "whats_next"),
|
JsonInput.String(input, "whatsNext"),
|
||||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
|
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,11 +347,11 @@ public class NovelAgentToolset(
|
|||||||
"delete_beat",
|
"delete_beat",
|
||||||
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
|
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("beat_id", "Id of the beat to delete.", required: true)
|
.Str("beatId", "Id of the beat to delete.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var beatId = JsonInput.RequiredGuid(input, "beat_id");
|
var beatId = JsonInput.RequiredGuid(input, "beatId");
|
||||||
return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
|
return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,16 +360,16 @@ public class NovelAgentToolset(
|
|||||||
"Renumber a chapter's beats to match the order given. List every beat id in the "
|
"Renumber a chapter's beats to match the order given. List every beat id in the "
|
||||||
+ "order you want; any you leave out keep their relative position at the end.",
|
+ "order you want; any you leave out keep their relative position at the end.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
|
.Str("chapterId", "Id of the chapter whose beats to reorder.", required: true)
|
||||||
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
|
.StringArray("beatIds", "Beat ids in their new order.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
|
||||||
return await OrNotFound(beats.ReorderAsync(
|
return await OrNotFound(beats.ReorderAsync(
|
||||||
chapterId,
|
chapterId,
|
||||||
new ReorderBeatsRequest(
|
new ReorderBeatsRequest(
|
||||||
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
[.. (JsonInput.Strings(input, "beatIds") ?? [])
|
||||||
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
||||||
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
||||||
});
|
});
|
||||||
@@ -306,18 +379,18 @@ public class NovelAgentToolset(
|
|||||||
"Add a character to several beats at once. Leaves each beat's existing characters and "
|
"Add a character to several beats at once. Leaves each beat's existing characters and "
|
||||||
+ "other fields alone — this only adds, it never removes.",
|
+ "other fields alone — this only adds, it never removes.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter the beats belong to.", required: true)
|
.Str("chapterId", "Id of the chapter the beats belong to.", required: true)
|
||||||
.Str("character_id", "Id of the character to add.", required: true)
|
.Str("characterId", "Id of the character to add.", required: true)
|
||||||
.StringArray("beat_ids", "Ids of the beats to add the character to.", required: true)
|
.StringArray("beatIds", "Ids of the beats to add the character to.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
|
||||||
return await OrNotFound(beats.AssignCharacterAsync(
|
return await OrNotFound(beats.AssignCharacterAsync(
|
||||||
chapterId,
|
chapterId,
|
||||||
new AssignCharacterToBeatsRequest(
|
new AssignCharacterToBeatsRequest(
|
||||||
JsonInput.RequiredGuid(input, "character_id"),
|
JsonInput.RequiredGuid(input, "characterId"),
|
||||||
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
[.. (JsonInput.Strings(input, "beatIds") ?? [])
|
||||||
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
||||||
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
||||||
});
|
});
|
||||||
@@ -327,18 +400,18 @@ public class NovelAgentToolset(
|
|||||||
"Move one or more beats from one chapter to another, appending them to the target "
|
"Move one or more beats from one chapter to another, appending them to the target "
|
||||||
+ "chapter's end in the order given.",
|
+ "chapter's end in the order given.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the beats' current chapter.", required: true)
|
.Str("chapterId", "Id of the beats' current chapter.", required: true)
|
||||||
.Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true)
|
.Str("targetChapterId", "Id of the chapter to move the beats into.", required: true)
|
||||||
.StringArray("beat_ids", "Ids of the beats to move.", required: true)
|
.StringArray("beatIds", "Ids of the beats to move.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
|
||||||
return await OrNotFound(beats.MoveAsync(
|
return await OrNotFound(beats.MoveAsync(
|
||||||
chapterId,
|
chapterId,
|
||||||
new MoveBeatsRequest(
|
new MoveBeatsRequest(
|
||||||
JsonInput.RequiredGuid(input, "target_chapter_id"),
|
JsonInput.RequiredGuid(input, "targetChapterId"),
|
||||||
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
[.. (JsonInput.Strings(input, "beatIds") ?? [])
|
||||||
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
||||||
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
||||||
});
|
});
|
||||||
@@ -348,18 +421,19 @@ public class NovelAgentToolset(
|
|||||||
"List the novel's tags with how many characters, chapters and beats carry each. "
|
"List the novel's tags with how many characters, chapters and beats carry each. "
|
||||||
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
|
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
|
||||||
new JsonSchemaBuilder().Build(),
|
new JsonSchemaBuilder().Build(),
|
||||||
async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
|
async (novelId, _, ct) => await tags.ListAsync(novelId, ct),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"get_tag_references",
|
"get_tag_references",
|
||||||
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
|
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
|
||||||
+ "trace a motif or a thread through the book.",
|
+ "trace a motif or a thread through the book.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("tag_id", "Id of the tag to trace.", required: true)
|
.Str("tagId", "Id of the tag to trace.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var tagId = JsonInput.RequiredGuid(input, "tag_id");
|
var tagId = JsonInput.RequiredGuid(input, "tagId");
|
||||||
var tag = await tags.GetReferencesAsync(tagId, ct);
|
var tag = await tags.GetReferencesAsync(tagId, ct);
|
||||||
if (tag is null)
|
if (tag is null)
|
||||||
{
|
{
|
||||||
@@ -370,22 +444,68 @@ public class NovelAgentToolset(
|
|||||||
return tag.ToReferencesResponse(displayNumbers);
|
return tag.ToReferencesResponse(displayNumbers);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"create_tag",
|
||||||
|
"Create a tag explicitly. Applying an unknown tag by name to a character, chapter or "
|
||||||
|
+ "beat also creates it, so this is only needed to set a colour up front.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("name", "The tag's name. Unique within the novel, matched case-insensitively.", required: true)
|
||||||
|
.Str("color", "Optional hex colour for the UI, e.g. \"#9a4a2f\".")
|
||||||
|
.Build(),
|
||||||
|
async (novelId, input, ct) => await OrNotFound(tags.CreateAsync(
|
||||||
|
novelId,
|
||||||
|
new CreateTagRequest(
|
||||||
|
JsonInput.RequiredString(input, "name"),
|
||||||
|
JsonInput.String(input, "color")), ct), t => t.ToResponse(), "Novel", novelId),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"update_tag",
|
||||||
|
"Rename or recolour a tag. Renaming updates it everywhere it is applied.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("tagId", "Id of the tag to update.", required: true)
|
||||||
|
.Str("name", "New name.")
|
||||||
|
.Str("color", "Hex colour, e.g. \"#9a4a2f\".")
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var tagId = JsonInput.RequiredGuid(input, "tagId");
|
||||||
|
return await OrNotFound(tags.UpdateAsync(
|
||||||
|
tagId,
|
||||||
|
new UpdateTagRequest(
|
||||||
|
JsonInput.String(input, "name"),
|
||||||
|
JsonInput.String(input, "color")), ct), t => t.ToResponse(), "Tag", tagId);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"delete_tag",
|
||||||
|
"Delete a tag. Whatever carried it is left alone — only the label goes.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("tagId", "Id of the tag to delete.", required: true)
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var tagId = JsonInput.RequiredGuid(input, "tagId");
|
||||||
|
return await DeletedOrNotFound(tags.DeleteAsync(tagId, ct), "Tag", tagId);
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"list_locations",
|
"list_locations",
|
||||||
"List the novel's locations with how many chapters are set there. "
|
"List the novel's locations with how many chapters are set there. "
|
||||||
+ "Read this before inventing a new location so you reuse the writer's vocabulary.",
|
+ "Read this before inventing a new location so you reuse the writer's vocabulary.",
|
||||||
new JsonSchemaBuilder().Build(),
|
new JsonSchemaBuilder().Build(),
|
||||||
async (novelId, _, ct) => await locations.ListAsync(novelId, ct));
|
async (novelId, _, ct) => await locations.ListAsync(novelId, ct),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"get_location_references",
|
"get_location_references",
|
||||||
"Cross-reference a location: every chapter set there.",
|
"Cross-reference a location: every chapter set there.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("location_id", "Id of the location to trace.", required: true)
|
.Str("locationId", "Id of the location to trace.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var locationId = JsonInput.RequiredGuid(input, "location_id");
|
var locationId = JsonInput.RequiredGuid(input, "locationId");
|
||||||
var location = await locations.GetReferencesAsync(locationId, ct);
|
var location = await locations.GetReferencesAsync(locationId, ct);
|
||||||
if (location is null)
|
if (location is null)
|
||||||
{
|
{
|
||||||
@@ -396,6 +516,45 @@ public class NovelAgentToolset(
|
|||||||
return location.ToReferencesResponse(displayNumbers);
|
return location.ToReferencesResponse(displayNumbers);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"create_location",
|
||||||
|
"Create a location explicitly. Applying an unknown location by name to a chapter also "
|
||||||
|
+ "creates it, so this is only needed to set one up ahead of time.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("name", "The location's name. Unique within the novel, matched case-insensitively.", required: true)
|
||||||
|
.Build(),
|
||||||
|
async (novelId, input, ct) => await OrNotFound(locations.CreateAsync(
|
||||||
|
novelId,
|
||||||
|
new CreateLocationRequest(JsonInput.RequiredString(input, "name")), ct), l => l.ToResponse(), "Novel", novelId),
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"update_location",
|
||||||
|
"Rename a location. Renaming updates it everywhere it is applied.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("locationId", "Id of the location to update.", required: true)
|
||||||
|
.Str("name", "New name.", required: true)
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var locationId = JsonInput.RequiredGuid(input, "locationId");
|
||||||
|
return await OrNotFound(locations.UpdateAsync(
|
||||||
|
locationId,
|
||||||
|
new UpdateLocationRequest(JsonInput.RequiredString(input, "name")), ct), l => l.ToResponse(), "Location", locationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"delete_location",
|
||||||
|
"Delete a location. Whatever carried it is left alone — only the label goes.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("locationId", "Id of the location to delete.", required: true)
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var locationId = JsonInput.RequiredGuid(input, "locationId");
|
||||||
|
return await DeletedOrNotFound(locations.DeleteAsync(locationId, ct), "Location", locationId);
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"list_chapters",
|
"list_chapters",
|
||||||
"List the novel's chapters in manuscript order with beat and word counts.",
|
"List the novel's chapters in manuscript order with beat and word counts.",
|
||||||
@@ -405,17 +564,18 @@ public class NovelAgentToolset(
|
|||||||
var list = await chapters.ListAsync(novelId, ct);
|
var list = await chapters.ListAsync(novelId, ct);
|
||||||
var displayNumbers = ChapterNumbering.DisplayNumbers(list);
|
var displayNumbers = ChapterNumbering.DisplayNumbers(list);
|
||||||
return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null));
|
return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null));
|
||||||
});
|
},
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"get_chapter",
|
"get_chapter",
|
||||||
"Read one chapter in full: its outline (beats) and its drafted prose.",
|
"Read one chapter in full: its outline (beats) and its drafted prose.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
.Str("chapterId", "Id of the chapter to read.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
|
||||||
var chapter = await chapters.GetAsync(chapterId, ct);
|
var chapter = await chapters.GetAsync(chapterId, ct);
|
||||||
if (chapter is null)
|
if (chapter is null)
|
||||||
{
|
{
|
||||||
@@ -439,7 +599,7 @@ public class NovelAgentToolset(
|
|||||||
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
|
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
|
||||||
.Str("notes", "Anything else worth recording.")
|
.Str("notes", "Anything else worth recording.")
|
||||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||||
.Int("target_word_count", "Target length in words.")
|
.Int("targetWordCount", "Target length in words.")
|
||||||
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
||||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||||
.Build(),
|
.Build(),
|
||||||
@@ -453,7 +613,7 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.Strings(input, "locations"),
|
JsonInput.Strings(input, "locations"),
|
||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||||
JsonInput.Int(input, "target_word_count"),
|
JsonInput.Int(input, "targetWordCount"),
|
||||||
JsonInput.String(input, "prose"),
|
JsonInput.String(input, "prose"),
|
||||||
JsonInput.Strings(input, "tags")), ct);
|
JsonInput.Strings(input, "tags")), ct);
|
||||||
|
|
||||||
@@ -464,7 +624,8 @@ public class NovelAgentToolset(
|
|||||||
|
|
||||||
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
|
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
|
||||||
return chapter.ToResponse(displayNumber);
|
return chapter.ToResponse(displayNumber);
|
||||||
});
|
},
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"update_chapter",
|
"update_chapter",
|
||||||
@@ -472,7 +633,7 @@ public class NovelAgentToolset(
|
|||||||
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
||||||
+ "word count is recomputed automatically.",
|
+ "word count is recomputed automatically.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
.Str("chapterId", "Id of the chapter to update.", required: true)
|
||||||
.Str("title", "New title.")
|
.Str("title", "New title.")
|
||||||
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
|
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
|
||||||
.Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames<ChapterKind>())
|
.Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames<ChapterKind>())
|
||||||
@@ -480,13 +641,13 @@ public class NovelAgentToolset(
|
|||||||
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
|
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
|
||||||
.Str("notes", "Anything else worth recording.")
|
.Str("notes", "Anything else worth recording.")
|
||||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||||
.Int("target_word_count", "Target length in words.")
|
.Int("targetWordCount", "Target length in words.")
|
||||||
.Str("prose", "The chapter's drafted text, in markdown.")
|
.Str("prose", "The chapter's drafted text, in markdown.")
|
||||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
|
||||||
var chapter = await chapters.UpdateAsync(
|
var chapter = await chapters.UpdateAsync(
|
||||||
chapterId,
|
chapterId,
|
||||||
new UpdateChapterRequest(
|
new UpdateChapterRequest(
|
||||||
@@ -497,7 +658,7 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.Strings(input, "locations"),
|
JsonInput.Strings(input, "locations"),
|
||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||||
JsonInput.Int(input, "target_word_count"),
|
JsonInput.Int(input, "targetWordCount"),
|
||||||
JsonInput.String(input, "prose"),
|
JsonInput.String(input, "prose"),
|
||||||
JsonInput.Strings(input, "tags")), ct);
|
JsonInput.Strings(input, "tags")), ct);
|
||||||
|
|
||||||
@@ -516,11 +677,11 @@ public class NovelAgentToolset(
|
|||||||
+ "Read this before revising a character — it is what they actually do on the page, "
|
+ "Read this before revising a character — it is what they actually do on the page, "
|
||||||
+ "as opposed to what the dossier claims about them.",
|
+ "as opposed to what the dossier claims about them.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("character_id", "Id of the character.", required: true)
|
.Str("characterId", "Id of the character.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) =>
|
async (novelId, input, ct) =>
|
||||||
{
|
{
|
||||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
var characterBeats = await beats.ListForCharacterAsync(characterId, ct);
|
var characterBeats = await beats.ListForCharacterAsync(characterId, ct);
|
||||||
if (characterBeats is null)
|
if (characterBeats is null)
|
||||||
{
|
{
|
||||||
@@ -530,66 +691,67 @@ public class NovelAgentToolset(
|
|||||||
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
|
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
|
||||||
return characterBeats.Select(b =>
|
return characterBeats.Select(b =>
|
||||||
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers)));
|
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers)));
|
||||||
});
|
},
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"get_character_arc",
|
"get_character_arc",
|
||||||
"Read a main character's arc: the ordered stages of how they change. Each stage may "
|
"Read a main character's arc: the ordered stages of how they change. Each stage may "
|
||||||
+ "be pinned to the chapter where it lands.",
|
+ "be pinned to the chapter where it lands.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("character_id", "Id of the character.", required: true)
|
.Str("characterId", "Id of the character.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) => (await arcs.ListAsync(
|
async (_, input, ct) => (await arcs.ListAsync(
|
||||||
JsonInput.RequiredGuid(input, "character_id"), ct)).Select(s => s.ToResponse()));
|
JsonInput.RequiredGuid(input, "characterId"), ct)).Select(s => s.ToResponse()));
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"add_arc_stage",
|
"add_arc_stage",
|
||||||
"Add a stage to a character's arc. Arcs are for main characters — promote the "
|
"Add a stage to a character's arc. Arcs are for main characters — promote the "
|
||||||
+ "character first with update_character if they are still Supporting.",
|
+ "character first with update_character if they are still Supporting.",
|
||||||
ArcStageSchema()
|
ArcStageSchema()
|
||||||
.Str("character_id", "Id of the character whose arc to add to.", required: true)
|
.Str("characterId", "Id of the character whose arc to add to.", required: true)
|
||||||
.Str("title", "A short handle for the change, three to five words.", required: true)
|
.Str("title", "A short handle for the change, three to five words.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
return await OrNotFound(arcs.CreateAsync(
|
return await OrNotFound(arcs.CreateAsync(
|
||||||
characterId,
|
characterId,
|
||||||
new CreateArcStageRequest(
|
new CreateArcStageRequest(
|
||||||
JsonInput.RequiredString(input, "title"),
|
JsonInput.RequiredString(input, "title"),
|
||||||
JsonInput.Int(input, "sort_order"),
|
JsonInput.Int(input, "sortOrder"),
|
||||||
JsonInput.String(input, "description"),
|
JsonInput.String(input, "description"),
|
||||||
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "Character", characterId);
|
JsonInput.Guid(input, "chapterId")), ct), s => s.ToResponse(), "Character", characterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"update_arc_stage",
|
"update_arc_stage",
|
||||||
"Revise a stage of a character's arc. Only the fields you supply change.",
|
"Revise a stage of a character's arc. Only the fields you supply change.",
|
||||||
ArcStageSchema()
|
ArcStageSchema()
|
||||||
.Str("arc_stage_id", "Id of the arc stage to update.", required: true)
|
.Str("arcStageId", "Id of the arc stage to update.", required: true)
|
||||||
.Str("title", "New title for the stage.")
|
.Str("title", "New title for the stage.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
|
var arcStageId = JsonInput.RequiredGuid(input, "arcStageId");
|
||||||
return await OrNotFound(arcs.UpdateAsync(
|
return await OrNotFound(arcs.UpdateAsync(
|
||||||
arcStageId,
|
arcStageId,
|
||||||
new UpdateArcStageRequest(
|
new UpdateArcStageRequest(
|
||||||
JsonInput.String(input, "title"),
|
JsonInput.String(input, "title"),
|
||||||
JsonInput.Int(input, "sort_order"),
|
JsonInput.Int(input, "sortOrder"),
|
||||||
JsonInput.String(input, "description"),
|
JsonInput.String(input, "description"),
|
||||||
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
|
JsonInput.Guid(input, "chapterId")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"delete_arc_stage",
|
"delete_arc_stage",
|
||||||
"Remove a stage from a character's arc.",
|
"Remove a stage from a character's arc.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("arc_stage_id", "Id of the arc stage to delete.", required: true)
|
.Str("arcStageId", "Id of the arc stage to delete.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
|
var arcStageId = JsonInput.RequiredGuid(input, "arcStageId");
|
||||||
return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
|
return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -598,16 +760,35 @@ public class NovelAgentToolset(
|
|||||||
"Renumber a character's arc to match the order given. Stages left out keep their "
|
"Renumber a character's arc to match the order given. Stages left out keep their "
|
||||||
+ "relative position after the ones listed.",
|
+ "relative position after the ones listed.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("character_id", "Id of the character whose arc to reorder.", required: true)
|
.Str("characterId", "Id of the character whose arc to reorder.", required: true)
|
||||||
.StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true)
|
.StringArray("stageIds", "Arc stage ids in the order wanted.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
var characterId = JsonInput.RequiredGuid(input, "characterId");
|
||||||
return await OrNotFound(arcs.ReorderAsync(
|
return await OrNotFound(arcs.ReorderAsync(
|
||||||
characterId,
|
characterId,
|
||||||
new ReorderArcStagesRequest(
|
new ReorderArcStagesRequest(
|
||||||
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId);
|
[.. JsonInput.Strings(input, "stageIds")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"set_arc_stage_beats",
|
||||||
|
"Set which beats belong to an arc stage, replacing its current set. This groups the "
|
||||||
|
+ "chapter-level beats that establish or pay off this stage of the character's arc. A "
|
||||||
|
+ "beat moved into this stage leaves any other stage of the same character it was in. "
|
||||||
|
+ "Each beat must already include this character.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("arcStageId", "Id of the arc stage.", required: true)
|
||||||
|
.StringArray("beatIds", "Beat ids that belong to this stage, replacing whatever was there before.", required: true)
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var arcStageId = JsonInput.RequiredGuid(input, "arcStageId");
|
||||||
|
return await OrNotFound(arcs.SetBeatsAsync(
|
||||||
|
arcStageId,
|
||||||
|
new SetArcStageBeatsRequest(
|
||||||
|
[.. JsonInput.Strings(input, "beatIds")?.Select(Guid.Parse) ?? []]), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
@@ -615,22 +796,23 @@ public class NovelAgentToolset(
|
|||||||
"The decisions the writer has not made yet. Read this before proposing changes — an "
|
"The decisions the writer has not made yet. Read this before proposing changes — an "
|
||||||
+ "open question is a place the writer is still thinking, not a gap to fill in for them.",
|
+ "open question is a place the writer is still thinking, not a gap to fill in for them.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Narrow to questions about one chapter outline.")
|
.Str("chapterId", "Narrow to questions about one chapter outline.")
|
||||||
.Str("character_id", "Narrow to questions about one character.")
|
.Str("characterId", "Narrow to questions about one character.")
|
||||||
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
.Bool("includeResolved", "Include questions already settled. Defaults to false.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) =>
|
async (novelId, input, ct) =>
|
||||||
{
|
{
|
||||||
var list = await questions.ListAsync(
|
var list = await questions.ListAsync(
|
||||||
novelId,
|
novelId,
|
||||||
JsonInput.Guid(input, "chapter_id"),
|
JsonInput.Guid(input, "chapterId"),
|
||||||
JsonInput.Guid(input, "character_id"),
|
JsonInput.Guid(input, "characterId"),
|
||||||
JsonInput.Bool(input, "include_resolved") ?? false,
|
JsonInput.Bool(input, "includeResolved") ?? false,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
|
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
|
||||||
return list.Select(q => q.ToResponse(displayNumbers));
|
return list.Select(q => q.ToResponse(displayNumbers));
|
||||||
});
|
},
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"raise_open_question",
|
"raise_open_question",
|
||||||
@@ -640,8 +822,8 @@ public class NovelAgentToolset(
|
|||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("question", "The question, in one line.", required: true)
|
.Str("question", "The question, in one line.", required: true)
|
||||||
.Str("detail", "The thinking around it — options, and what each costs.")
|
.Str("detail", "The thinking around it — options, and what each costs.")
|
||||||
.Str("chapter_id", "The chapter outline this is about, if any.")
|
.Str("chapterId", "The chapter outline this is about, if any.")
|
||||||
.Str("character_id", "The character this is about, if any.")
|
.Str("characterId", "The character this is about, if any.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) =>
|
async (novelId, input, ct) =>
|
||||||
{
|
{
|
||||||
@@ -650,8 +832,8 @@ public class NovelAgentToolset(
|
|||||||
new CreateOpenQuestionRequest(
|
new CreateOpenQuestionRequest(
|
||||||
JsonInput.RequiredString(input, "question"),
|
JsonInput.RequiredString(input, "question"),
|
||||||
JsonInput.String(input, "detail"),
|
JsonInput.String(input, "detail"),
|
||||||
JsonInput.Guid(input, "chapter_id"),
|
JsonInput.Guid(input, "chapterId"),
|
||||||
JsonInput.Guid(input, "character_id")), ct);
|
JsonInput.Guid(input, "characterId")), ct);
|
||||||
|
|
||||||
if (question is null)
|
if (question is null)
|
||||||
{
|
{
|
||||||
@@ -660,25 +842,60 @@ public class NovelAgentToolset(
|
|||||||
|
|
||||||
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
|
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
|
||||||
return question.ToResponse(displayNumbers);
|
return question.ToResponse(displayNumbers);
|
||||||
|
},
|
||||||
|
RequiresNovelId: true);
|
||||||
|
|
||||||
|
yield return new AgentTool(
|
||||||
|
"update_open_question",
|
||||||
|
"Revise a question or change what it is attached to. Only the fields you supply change.",
|
||||||
|
new JsonSchemaBuilder()
|
||||||
|
.Str("questionId", "Id of the question to update.", required: true)
|
||||||
|
.Str("question", "New wording for the question.")
|
||||||
|
.Str("detail", "New detail. Pass an empty string to clear it.")
|
||||||
|
.Str("chapterId", "Attach to this chapter outline.")
|
||||||
|
.Str("characterId", "Attach to this character.")
|
||||||
|
.Bool("clearChapter", "Detach from its chapter.")
|
||||||
|
.Bool("clearCharacter", "Detach from its character.")
|
||||||
|
.Build(),
|
||||||
|
async (_, input, ct) =>
|
||||||
|
{
|
||||||
|
var questionId = JsonInput.RequiredGuid(input, "questionId");
|
||||||
|
var question = await questions.UpdateAsync(
|
||||||
|
questionId,
|
||||||
|
new UpdateOpenQuestionRequest(
|
||||||
|
JsonInput.String(input, "question"),
|
||||||
|
JsonInput.String(input, "detail"),
|
||||||
|
JsonInput.Guid(input, "chapterId"),
|
||||||
|
JsonInput.Guid(input, "characterId"),
|
||||||
|
JsonInput.Bool(input, "clearChapter") ?? false,
|
||||||
|
JsonInput.Bool(input, "clearCharacter") ?? false), ct);
|
||||||
|
|
||||||
|
if (question is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("OpenQuestion", questionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
|
||||||
|
return question.ToResponse(displayNumbers);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"resolve_open_question",
|
"resolve_open_question",
|
||||||
"Settle a question with what the writer decided. Set append_to_notes to also write "
|
"Settle a question with what the writer decided. Set appendToNotes to also write "
|
||||||
+ "the resolution into the notes of the chapter and character it hangs off.",
|
+ "the resolution into the notes of the chapter and character it hangs off.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("question_id", "Id of the question to resolve.", required: true)
|
.Str("questionId", "Id of the question to resolve.", required: true)
|
||||||
.Str("resolution", "What was decided.", required: true)
|
.Str("resolution", "What was decided.", required: true)
|
||||||
.Bool("append_to_notes", "Also append the resolution to the associated notes.")
|
.Bool("appendToNotes", "Also append the resolution to the associated notes.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
var questionId = JsonInput.RequiredGuid(input, "questionId");
|
||||||
var question = await questions.ResolveAsync(
|
var question = await questions.ResolveAsync(
|
||||||
questionId,
|
questionId,
|
||||||
new ResolveOpenQuestionRequest(
|
new ResolveOpenQuestionRequest(
|
||||||
JsonInput.RequiredString(input, "resolution"),
|
JsonInput.RequiredString(input, "resolution"),
|
||||||
JsonInput.Bool(input, "append_to_notes") ?? false), ct);
|
JsonInput.Bool(input, "appendToNotes") ?? false), ct);
|
||||||
|
|
||||||
if (question is null)
|
if (question is null)
|
||||||
{
|
{
|
||||||
@@ -693,11 +910,11 @@ public class NovelAgentToolset(
|
|||||||
"reopen_question",
|
"reopen_question",
|
||||||
"Put a resolved question back on the list. Anything already appended to notes stays.",
|
"Put a resolved question back on the list. Anything already appended to notes stays.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("question_id", "Id of the question to reopen.", required: true)
|
.Str("questionId", "Id of the question to reopen.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
var questionId = JsonInput.RequiredGuid(input, "questionId");
|
||||||
var question = await questions.ReopenAsync(questionId, ct);
|
var question = await questions.ReopenAsync(questionId, ct);
|
||||||
if (question is null)
|
if (question is null)
|
||||||
{
|
{
|
||||||
@@ -712,20 +929,20 @@ public class NovelAgentToolset(
|
|||||||
"delete_open_question",
|
"delete_open_question",
|
||||||
"Delete a question outright. Resolving is usually better — it keeps the decision.",
|
"Delete a question outright. Resolving is usually better — it keeps the decision.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("question_id", "Id of the question to delete.", required: true)
|
.Str("questionId", "Id of the question to delete.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
var questionId = JsonInput.RequiredGuid(input, "questionId");
|
||||||
return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId);
|
return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JsonSchemaBuilder ArcStageSchema() =>
|
private static JsonSchemaBuilder ArcStageSchema() =>
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Int("sort_order", "Position in the arc. Appended to the end when omitted.")
|
.Int("sortOrder", "Position in the arc. Appended to the end when omitted.")
|
||||||
.Str("description", "What shifts in the character here, and what it costs them.")
|
.Str("description", "What shifts in the character here, and what it costs them.")
|
||||||
.Str("chapter_id", "The chapter where this stage lands, if it is pinned to one.");
|
.Str("chapterId", "The chapter where this stage lands, if it is pinned to one.");
|
||||||
|
|
||||||
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
|
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
|
||||||
{
|
{
|
||||||
@@ -759,9 +976,9 @@ public class NovelAgentToolset(
|
|||||||
|
|
||||||
private static JsonSchemaBuilder BeatSchema() =>
|
private static JsonSchemaBuilder BeatSchema() =>
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
|
.Int("sortOrder", "Position in the chapter. Appended to the end when omitted.")
|
||||||
.StringArray("character_ids", "Ids of the characters whose beat this is. Replaces the existing list.")
|
.StringArray("characterIds", "Ids of the characters whose beat this is. Replaces the existing list.")
|
||||||
.Str("what_happened", "The event itself.")
|
.Str("whatHappened", "The event itself.")
|
||||||
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
|
.Str("whatsNext", "What it sets in motion — the hook into the next beat.")
|
||||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class BeatEntityTypeConfiguration : IEntityTypeConfiguration<Beat>
|
|||||||
{
|
{
|
||||||
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
|
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
|
||||||
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
|
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
|
||||||
|
entity.HasQueryFilter(b => b.Chapter!.DeletedAt == null);
|
||||||
|
|
||||||
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
|
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
|
||||||
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ public static class BeatMapping
|
|||||||
b.ChapterId,
|
b.ChapterId,
|
||||||
b.SortOrder,
|
b.SortOrder,
|
||||||
b.Title,
|
b.Title,
|
||||||
[.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
|
[.. b.Characters.Where(c => c.DeletedAt is null).OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
|
||||||
b.WhatHappened,
|
b.WhatHappened,
|
||||||
b.WhatsNext,
|
b.WhatsNext,
|
||||||
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public class BeatService(
|
|||||||
|
|
||||||
var beats = await db.Beats
|
var beats = await db.Beats
|
||||||
.Include(b => b.Chapter)
|
.Include(b => b.Chapter)
|
||||||
.Include(b => b.ArcStages)
|
.Include(b => b.ArcStages.Where(s => s.Character!.DeletedAt == null))
|
||||||
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ public class BeatService(
|
|||||||
|
|
||||||
private IQueryable<Beat> Query() =>
|
private IQueryable<Beat> Query() =>
|
||||||
db.Beats
|
db.Beats
|
||||||
.Include(b => b.Characters)
|
.Include(b => b.Characters.Where(c => c.DeletedAt == null))
|
||||||
.Include(b => b.Tags);
|
.Include(b => b.Tags);
|
||||||
|
|
||||||
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Novelly.Api.Tags;
|
|||||||
|
|
||||||
namespace Novelly.Api.Chapters;
|
namespace Novelly.Api.Chapters;
|
||||||
|
|
||||||
public class Chapter
|
public class Chapter : ISoftDeletable
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid NovelId { get; set; }
|
public Guid NovelId { get; set; }
|
||||||
@@ -33,6 +33,7 @@ public class Chapter
|
|||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
|
||||||
public List<Beat> Beats { get; set; } = [];
|
public List<Beat> Beats { get; set; } = [];
|
||||||
|
|
||||||
@@ -48,5 +49,6 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
|
|||||||
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
||||||
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
|
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
|
||||||
entity.HasIndex(c => new { c.NovelId, c.Number });
|
entity.HasIndex(c => new { c.NovelId, c.Number });
|
||||||
|
entity.HasQueryFilter(c => c.DeletedAt == null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ public static class ChapterMapping
|
|||||||
{
|
{
|
||||||
public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
|
public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
|
||||||
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
|
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
|
||||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
[.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||||
c.Notes,
|
c.Notes,
|
||||||
c.Status, c.TargetWordCount,
|
c.Status, c.TargetWordCount,
|
||||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
||||||
@@ -133,7 +133,7 @@ public static class ChapterMapping
|
|||||||
|
|
||||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
|
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
|
||||||
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
|
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
|
||||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
[.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||||
c.Status, c.TargetWordCount,
|
c.Status, c.TargetWordCount,
|
||||||
c.Beats.Count, c.WordCount,
|
c.Beats.Count, c.WordCount,
|
||||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public static class ChapterEndpoints
|
|||||||
|
|
||||||
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||||
.WithSummary("Delete a chapter.");
|
.WithSummary("Move a chapter to the trash.");
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class ChapterService(
|
|||||||
return await db.Chapters
|
return await db.Chapters
|
||||||
.Include(c => c.Beats)
|
.Include(c => c.Beats)
|
||||||
.Include(c => c.Tags)
|
.Include(c => c.Tags)
|
||||||
.Include(c => c.Locations)
|
.Include(c => c.Locations.Where(l => l.DeletedAt == null))
|
||||||
.Where(c => c.NovelId == novelId)
|
.Where(c => c.NovelId == novelId)
|
||||||
.OrderBy(c => c.Number)
|
.OrderBy(c => c.Number)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
@@ -153,17 +153,18 @@ public class ChapterService(
|
|||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
|
|
||||||
logger.LogInformation("Deleting chapter {ChapterId}", id);
|
logger.LogInformation("Moving chapter {ChapterId} to trash", id);
|
||||||
|
|
||||||
var chapter = await FindAsync(id, ct);
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||||
if (chapter is null)
|
if (chapter is null)
|
||||||
{
|
{
|
||||||
|
logger.LogWarning("Chapter {ChapterId} not found", id);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.Chapters.Remove(chapter);
|
chapter.DeletedAt = DateTimeOffset.UtcNow;
|
||||||
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
|
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
@@ -177,6 +178,7 @@ public class ChapterService(
|
|||||||
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
|
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
|
||||||
|
|
||||||
var max = await db.Chapters
|
var max = await db.Chapters
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.Where(c => c.NovelId == novelId)
|
.Where(c => c.NovelId == novelId)
|
||||||
.MaxAsync(c => (int?)c.Number, ct);
|
.MaxAsync(c => (int?)c.Number, ct);
|
||||||
|
|
||||||
@@ -190,10 +192,10 @@ public class ChapterService(
|
|||||||
logger.LogDebug("Finding chapter {ChapterId}", id);
|
logger.LogDebug("Finding chapter {ChapterId}", id);
|
||||||
|
|
||||||
var chapter = await db.Chapters
|
var chapter = await db.Chapters
|
||||||
.Include(c => c.Beats).ThenInclude(b => b.Characters)
|
.Include(c => c.Beats).ThenInclude(b => b.Characters.Where(ch => ch.DeletedAt == null))
|
||||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||||
.Include(c => c.Tags)
|
.Include(c => c.Tags)
|
||||||
.Include(c => c.Locations)
|
.Include(c => c.Locations.Where(l => l.DeletedAt == null))
|
||||||
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||||
|
|
||||||
if (chapter is null)
|
if (chapter is null)
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Novels;
|
using Novelly.Api.Novels;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Characters;
|
namespace Novelly.Api.Characters;
|
||||||
|
|
||||||
public class Character
|
public class Character : ISoftDeletable
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid NovelId { get; set; }
|
public Guid NovelId { get; set; }
|
||||||
@@ -46,6 +47,7 @@ public class Character
|
|||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
|
||||||
public List<CharacterRelationship> Relationships { get; set; } = [];
|
public List<CharacterRelationship> Relationships { get; set; } = [];
|
||||||
public List<Tag> Tags { get; set; } = [];
|
public List<Tag> Tags { get; set; } = [];
|
||||||
@@ -79,6 +81,7 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
|
|||||||
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
|
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
|
||||||
entity.HasIndex(c => c.NovelId);
|
entity.HasIndex(c => c.NovelId);
|
||||||
entity.HasIndex(c => c.SameCharacterAsId);
|
entity.HasIndex(c => c.SameCharacterAsId);
|
||||||
|
entity.HasQueryFilter(c => c.DeletedAt == null);
|
||||||
|
|
||||||
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||||
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
||||||
@@ -102,5 +105,7 @@ public class CharacterRelationshipEntityTypeConfiguration : IEntityTypeConfigura
|
|||||||
|
|
||||||
entity.HasOne(r => r.RelatedCharacter).WithMany()
|
entity.HasOne(r => r.RelatedCharacter).WithMany()
|
||||||
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
entity.HasQueryFilter(r => r.Character!.DeletedAt == null && r.RelatedCharacter!.DeletedAt == null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ public class CharacterArcService(
|
|||||||
private IQueryable<CharacterArcStage> Query() =>
|
private IQueryable<CharacterArcStage> Query() =>
|
||||||
db.CharacterArcStages
|
db.CharacterArcStages
|
||||||
.Include(s => s.Chapter)
|
.Include(s => s.Chapter)
|
||||||
.Include(s => s.Beats).ThenInclude(b => b.Chapter);
|
.Include(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null)).ThenInclude(b => b.Chapter);
|
||||||
|
|
||||||
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
|
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
|
|||||||
{
|
{
|
||||||
entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
|
entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
|
||||||
entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
|
entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
|
||||||
|
entity.HasQueryFilter(s => s.Character!.DeletedAt == null);
|
||||||
|
|
||||||
entity.HasOne(s => s.Chapter).WithMany()
|
entity.HasOne(s => s.Chapter).WithMany()
|
||||||
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|||||||
@@ -294,37 +294,45 @@ public static class CharacterMapping
|
|||||||
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
|
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
|
||||||
[.. c.Aliases],
|
[.. c.Aliases],
|
||||||
c.SameCharacterAsId,
|
c.SameCharacterAsId,
|
||||||
c.SameCharacterAs?.Name,
|
c.SameCharacterAs is { DeletedAt: null } canonical ? canonical.Name : null,
|
||||||
c.RevealedInChapterId,
|
c.RevealedInChapterId,
|
||||||
c.RevealedInChapter?.Number,
|
c.RevealedInChapter is { DeletedAt: null } revealedInChapter ? revealedInChapter.Number : null,
|
||||||
c.RevealedInChapter is { } revealedInChapter ? ChapterLabel(revealedInChapter, displayNumbers) : null,
|
c.RevealedInChapter is { DeletedAt: null } revealedInChapter2 ? ChapterLabel(revealedInChapter2, displayNumbers) : null,
|
||||||
c.IdentityNote,
|
c.IdentityNote,
|
||||||
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
|
[.. c.OtherIdentities.Where(o => o.DeletedAt is null).OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
|
||||||
[.. c.Relationships.Select(r => new RelationshipResponse(
|
[.. c.Relationships
|
||||||
|
.Where(r => r.RelatedCharacter is { DeletedAt: null })
|
||||||
|
.Select(r => new RelationshipResponse(
|
||||||
r.Id,
|
r.Id,
|
||||||
r.RelatedCharacterId,
|
r.RelatedCharacterId,
|
||||||
r.RelatedCharacter?.Name ?? "(unknown)",
|
r.RelatedCharacter!.Name,
|
||||||
r.RelationshipType,
|
r.RelationshipType,
|
||||||
r.Description))],
|
r.Description))],
|
||||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))],
|
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))],
|
||||||
c.UpdatedAt);
|
c.UpdatedAt);
|
||||||
|
|
||||||
public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
|
public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null)
|
||||||
|
{
|
||||||
|
var chapter = s.Chapter is { DeletedAt: null } ? s.Chapter : null;
|
||||||
|
|
||||||
|
return new(
|
||||||
s.Id,
|
s.Id,
|
||||||
s.CharacterId,
|
s.CharacterId,
|
||||||
s.SortOrder,
|
s.SortOrder,
|
||||||
s.Title,
|
s.Title,
|
||||||
s.Result,
|
s.Result,
|
||||||
s.ChapterId,
|
s.ChapterId,
|
||||||
s.Chapter?.Number,
|
chapter?.Number,
|
||||||
s.Chapter?.Title,
|
chapter?.Title,
|
||||||
s.Chapter is { } chapter ? ChapterLabel(chapter, displayNumbers) : null,
|
chapter is not null ? ChapterLabel(chapter, displayNumbers) : null,
|
||||||
[.. s.Beats
|
[.. s.Beats
|
||||||
.OrderBy(b => b.Chapter?.Number ?? 0)
|
.Where(b => b.Chapter is { DeletedAt: null })
|
||||||
|
.OrderBy(b => b.Chapter!.Number)
|
||||||
.ThenBy(b => b.SortOrder)
|
.ThenBy(b => b.SortOrder)
|
||||||
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, b.Chapter is { } beatChapter ? ChapterLabel(beatChapter, displayNumbers) : null))],
|
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, ChapterLabel(b.Chapter!, displayNumbers)))],
|
||||||
s.UpdatedAt);
|
s.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) =>
|
private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) =>
|
||||||
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
|
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ public static class CharacterEndpoints
|
|||||||
|
|
||||||
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
||||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||||
.WithSummary("Delete a character.");
|
.WithSummary("Move a character to the trash.");
|
||||||
|
|
||||||
characters.MapPost("/{id:guid}/relationships", async (
|
characters.MapPost("/{id:guid}/relationships", async (
|
||||||
Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ public class CharacterService(
|
|||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
|
|
||||||
logger.LogInformation("Deleting character {CharacterId}", id);
|
logger.LogInformation("Moving character {CharacterId} to trash", id);
|
||||||
|
|
||||||
var character = await FindAsync(id, ct);
|
var character = await FindAsync(id, ct);
|
||||||
if (character is null)
|
if (character is null)
|
||||||
@@ -169,7 +169,7 @@ public class CharacterService(
|
|||||||
|
|
||||||
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.Characters.Remove(character);
|
character.DeletedAt = DateTimeOffset.UtcNow;
|
||||||
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id);
|
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
@@ -351,7 +351,7 @@ public class CharacterService(
|
|||||||
.Include(c => c.ArcStages)
|
.Include(c => c.ArcStages)
|
||||||
.ThenInclude(s => s.Chapter)
|
.ThenInclude(s => s.Chapter)
|
||||||
.Include(c => c.ArcStages)
|
.Include(c => c.ArcStages)
|
||||||
.ThenInclude(s => s.Beats)
|
.ThenInclude(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null))
|
||||||
.ThenInclude(b => b.Chapter)
|
.ThenInclude(b => b.Chapter)
|
||||||
.Include(c => c.SameCharacterAs)
|
.Include(c => c.SameCharacterAs)
|
||||||
.Include(c => c.OtherIdentities)
|
.Include(c => c.OtherIdentities)
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ using Novelly.Api.Data;
|
|||||||
using Novelly.Api.Genres;
|
using Novelly.Api.Genres;
|
||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Locations;
|
using Novelly.Api.Locations;
|
||||||
|
using Novelly.Api.Mcp;
|
||||||
using Novelly.Api.Novels;
|
using Novelly.Api.Novels;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
using Novelly.Api.Trash;
|
||||||
using Novelly.Api.Users;
|
using Novelly.Api.Users;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
namespace Novelly.Api.Common;
|
namespace Novelly.Api.Common;
|
||||||
|
|
||||||
@@ -94,14 +97,35 @@ public static class NovellyServiceRegistration
|
|||||||
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
||||||
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
||||||
|
|
||||||
|
services.Configure<ImportOptions>(configuration.GetSection(ImportOptions.SectionName));
|
||||||
services.AddSingleton(Channel.CreateUnbounded<Guid>());
|
services.AddSingleton(Channel.CreateUnbounded<Guid>());
|
||||||
services.AddScoped<ImportService>();
|
services.AddScoped<ImportService>();
|
||||||
|
services.AddScoped<ImportBrowseService>();
|
||||||
|
services.AddScoped<ImportZipExtractor>();
|
||||||
services.AddScoped<ImportAgentToolset>();
|
services.AddScoped<ImportAgentToolset>();
|
||||||
services.AddScoped<ImportAgentService>();
|
services.AddScoped<ImportAgentService>();
|
||||||
services.AddHostedService<ImportJobRunner>();
|
services.AddHostedService<ImportJobRunner>();
|
||||||
|
|
||||||
|
services.Configure<TrashOptions>(configuration.GetSection(TrashOptions.SectionName));
|
||||||
|
services.AddScoped<TrashService>();
|
||||||
|
services.AddSingleton(TimeProvider.System);
|
||||||
|
services.AddHostedService<TrashPurgeRunner>();
|
||||||
|
|
||||||
services.AddModelValidatorsFromAssemblyContaining<Program>();
|
services.AddModelValidatorsFromAssemblyContaining<Program>();
|
||||||
|
|
||||||
|
services.AddMcpServer(options => options.ServerInfo = new Implementation { Name = "novelly", Version = "1.0.0" })
|
||||||
|
.WithHttpTransport()
|
||||||
|
.WithListToolsHandler((request, ct) =>
|
||||||
|
{
|
||||||
|
var toolset = request.Services!.GetRequiredService<NovelAgentToolset>();
|
||||||
|
return ValueTask.FromResult(new ListToolsResult { Tools = [.. NovelMcpTools.Describe(toolset.Definitions)] });
|
||||||
|
})
|
||||||
|
.WithCallToolHandler((request, ct) =>
|
||||||
|
{
|
||||||
|
var toolset = request.Services!.GetRequiredService<NovelAgentToolset>();
|
||||||
|
return new ValueTask<CallToolResult>(NovelMcpTools.CallAsync(toolset, toolset.Definitions, request.Params!, ct));
|
||||||
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Novelly.Api.Common;
|
||||||
|
|
||||||
|
public interface ISoftDeletable
|
||||||
|
{
|
||||||
|
DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Novelly.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSoftDelete : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Locations_NovelId_Name",
|
||||||
|
table: "Locations");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Locations",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Characters",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Chapters",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Locations_NovelId_Name",
|
||||||
|
table: "Locations",
|
||||||
|
columns: new[] { "NovelId", "Name" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"DeletedAt\" IS NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Locations_NovelId_Name",
|
||||||
|
table: "Locations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Locations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Characters");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DeletedAt",
|
||||||
|
table: "Chapters");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Locations_NovelId_Name",
|
||||||
|
table: "Locations",
|
||||||
|
columns: new[] { "NovelId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,6 +319,9 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<long>("CreatedAt")
|
b.Property<long>("CreatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("DeletedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Kind")
|
b.Property<string>("Kind")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -390,6 +393,9 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<long>("CreatedAt")
|
b.Property<long>("CreatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("DeletedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("IdentityNote")
|
b.Property<string>("IdentityNote")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -680,6 +686,9 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<long>("CreatedAt")
|
b.Property<long>("CreatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("DeletedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
@@ -691,7 +700,8 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("NovelId", "Name")
|
b.HasIndex("NovelId", "Name")
|
||||||
.IsUnique();
|
.IsUnique()
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
b.ToTable("Locations");
|
b.ToTable("Locations");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ public class ImportAgentService(
|
|||||||
toolset.Initialize(sourceRoot, existingNovelId);
|
toolset.Initialize(sourceRoot, existingNovelId);
|
||||||
|
|
||||||
var startingLedger = toolset.ReadLedgerOrNull();
|
var startingLedger = toolset.ReadLedgerOrNull();
|
||||||
var systemPrompt = BuildSystemPrompt(sourceRoot);
|
var systemPrompt = ImportPaths.IsSingleFileSource(sourceRoot)
|
||||||
|
? BuildSingleFileSystemPrompt(sourceRoot)
|
||||||
|
: BuildSystemPrompt(sourceRoot);
|
||||||
|
|
||||||
var transcript = new List<AgentChatMessage>
|
var transcript = new List<AgentChatMessage>
|
||||||
{
|
{
|
||||||
@@ -105,6 +107,9 @@ public class ImportAgentService(
|
|||||||
|
|
||||||
private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
|
private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
|
||||||
|
|
||||||
|
private static string BuildSingleFileSystemPrompt(string sourceRoot) =>
|
||||||
|
SingleFileSystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
|
||||||
|
|
||||||
private const string SystemPromptTemplate = """
|
private const string SystemPromptTemplate = """
|
||||||
You import a novel outline that already exists as markdown files on disk into this
|
You import a novel outline that already exists as markdown files on disk into this
|
||||||
app's novel data. You are running unattended — nobody will read your replies or
|
app's novel data. You are running unattended — nobody will read your replies or
|
||||||
@@ -199,4 +204,63 @@ public class ImportAgentService(
|
|||||||
- If a tool call fails, stop that item and move on rather than retrying blindly —
|
- If a tool call fails, stop that item and move on rather than retrying blindly —
|
||||||
the ledger stays at the last successful write either way.
|
the ledger stays at the last successful write either way.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
private const string SingleFileSystemPromptTemplate = """
|
||||||
|
You import a single outline file that already exists as markdown on disk into this
|
||||||
|
app's novel data. You are running unattended — nobody will read your replies or
|
||||||
|
answer questions mid-run, so make the judgment calls yourself and record anything
|
||||||
|
genuinely ambiguous rather than stalling on it.
|
||||||
|
|
||||||
|
Your tools give you exactly two things: read-only access to the file under the
|
||||||
|
import source folder, and application tools that create the novel's chapters,
|
||||||
|
characters, beats and arcs. You cannot write or edit anything on disk except the
|
||||||
|
resume ledger, and you cannot read anything outside the source folder.
|
||||||
|
|
||||||
|
## Source file
|
||||||
|
|
||||||
|
The source root `{{SOURCE_ROOT}}` holds exactly one markdown file. Call
|
||||||
|
list_source_files to find its name, then read_source_file to read it. Decide what
|
||||||
|
kind of document it is before doing anything else:
|
||||||
|
|
||||||
|
- If it reads like a chapter outline (`# Chapter NN`, one or more summary
|
||||||
|
paragraphs, a beat table `| Beat | Character | What | Why |`) treat it as a single
|
||||||
|
chapter.
|
||||||
|
- If it reads like a character dossier (`# Name`, an italic tagline,
|
||||||
|
`## Appearance`, `## Background`, `## Motivation`) treat it as a single character.
|
||||||
|
|
||||||
|
`**Thread:**` (chapter files only) may name one character, several, or a character
|
||||||
|
plus a qualifier — only auto-create an undossiered name from it when it names
|
||||||
|
exactly one clear proper name.
|
||||||
|
|
||||||
|
## The ledger
|
||||||
|
|
||||||
|
Before writing anything, call read_ledger. If it returns `{{}}`, this is a fresh
|
||||||
|
run. Call write_ledger with the full, updated ledger after every successful write.
|
||||||
|
|
||||||
|
## Passes
|
||||||
|
|
||||||
|
1. **Novel** — skip if "novel" is in completedPasses or a novel id was already
|
||||||
|
supplied. Otherwise create one from whatever title/author information the file
|
||||||
|
gives, or a sensible placeholder title drawn from the file name if none is
|
||||||
|
present. Record novelId, mark "novel" done.
|
||||||
|
2. **The document** — skip if already recorded. If it is a chapter: auto-create a
|
||||||
|
character stub (name only) for any single, unqualified name in the Thread or a
|
||||||
|
beat's Character column that isn't in the ledger yet, then create_chapter with
|
||||||
|
title, number (1 unless the file states otherwise), summary, and tags, then
|
||||||
|
create_beat for each table row with resolved character_ids. If it is a
|
||||||
|
character: create_character with occupation from the tagline and
|
||||||
|
appearance/backstory/want from Appearance/Background/Motivation; if it has a
|
||||||
|
`## Events` section, also update_character(importance: "Main") and add_arc_stage
|
||||||
|
for each bullet. Mark "characters", "chapters", and "arcs" all done once you've
|
||||||
|
handled the one document — this run only ever has one item to place.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Never invent plot content or character detail, and never guess which of several
|
||||||
|
candidate names an ambiguous reference means.
|
||||||
|
- Never write to disk except via write_ledger.
|
||||||
|
- Never call a create tool for something the ledger already records.
|
||||||
|
- If a tool call fails, stop and record what you have — the ledger stays at the
|
||||||
|
last successful write either way.
|
||||||
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Imports;
|
||||||
|
|
||||||
|
public class ImportBrowseService(IOptions<ImportOptions> options, ILogger<ImportBrowseService> logger)
|
||||||
|
{
|
||||||
|
private readonly ImportOptions _options = options.Value;
|
||||||
|
|
||||||
|
public string? RootPath => _options.RootPath;
|
||||||
|
|
||||||
|
public ImportBrowseResponse List(string? relativePath)
|
||||||
|
{
|
||||||
|
var root = RequireRoot();
|
||||||
|
|
||||||
|
logger.LogInformation("Browsing import root at {RelativePath}", relativePath ?? "");
|
||||||
|
|
||||||
|
var target = string.IsNullOrWhiteSpace(relativePath) ? root : ImportPaths.ResolveWithin(root, relativePath);
|
||||||
|
|
||||||
|
if (!Directory.Exists(target))
|
||||||
|
throw new ArgumentException($"'{relativePath}' does not exist or is not a directory.", nameof(relativePath));
|
||||||
|
|
||||||
|
var normalizedRelative = Path.GetRelativePath(root, target).Replace(Path.DirectorySeparatorChar, '/');
|
||||||
|
if (normalizedRelative == ".")
|
||||||
|
{
|
||||||
|
normalizedRelative = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = normalizedRelative == "" ? null : Path.GetRelativePath(root, Path.GetFullPath(Path.Combine(target, ".."))).Replace(Path.DirectorySeparatorChar, '/');
|
||||||
|
if (parent == ".")
|
||||||
|
{
|
||||||
|
parent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = Directory.EnumerateFileSystemEntries(target)
|
||||||
|
.Select(BuildEntry)
|
||||||
|
.Where(e => e is not null)
|
||||||
|
.Select(e => e!)
|
||||||
|
.OrderByDescending(e => e.IsDirectory)
|
||||||
|
.ThenBy(e => e.Name, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new ImportBrowseResponse(normalizedRelative, parent, entries);
|
||||||
|
|
||||||
|
ImportBrowseEntry? BuildEntry(string path)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(path);
|
||||||
|
if (name.StartsWith('.'))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDirectory = Directory.Exists(path);
|
||||||
|
if (!isDirectory && !name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryRelative = Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
|
||||||
|
var markdownCount = isDirectory ? ImportPaths.CountChapterFiles(path) : 0;
|
||||||
|
var looksImportable = isDirectory
|
||||||
|
? File.Exists(Path.Combine(path, "outline.md")) || markdownCount > 0
|
||||||
|
: true;
|
||||||
|
|
||||||
|
return new ImportBrowseEntry(name, entryRelative, path, isDirectory, markdownCount, looksImportable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string RequireRoot()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.RootPath))
|
||||||
|
throw new InvalidOperationException("No import root is configured (Imports:RootPath).");
|
||||||
|
|
||||||
|
var full = Path.GetFullPath(_options.RootPath);
|
||||||
|
Directory.CreateDirectory(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ImportBrowseEntry(string Name, string RelativePath, string SourceRoot, bool IsDirectory, int MarkdownFileCount, bool LooksImportable);
|
||||||
|
|
||||||
|
public record ImportBrowseResponse(string RelativePath, string? ParentRelativePath, IReadOnlyList<ImportBrowseEntry> Entries);
|
||||||
@@ -57,6 +57,8 @@ public class StartImportRequestValidator : IModelValidator<StartImportRequest>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ImportUploadResponse(string SourceRoot, string RelativePath, int MarkdownFileCount);
|
||||||
|
|
||||||
public static class ImportMapping
|
public static class ImportMapping
|
||||||
{
|
{
|
||||||
public static ImportJobResponse ToResponse(this ImportJob job) => new(
|
public static ImportJobResponse ToResponse(this ImportJob job) => new(
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ public static class ImportEndpoints
|
|||||||
(await service.GetStatusAsync(id, ct))?.ToResponse().ToApiResult())
|
(await service.GetStatusAsync(id, ct))?.ToResponse().ToApiResult())
|
||||||
.WithSummary("Poll an import job's progress.");
|
.WithSummary("Poll an import job's progress.");
|
||||||
|
|
||||||
|
imports.MapGet("/browse", (string? path, ImportBrowseService browse) =>
|
||||||
|
Results.Ok(browse.List(path)))
|
||||||
|
.WithSummary("List entries under the configured import root, for the source picker.");
|
||||||
|
|
||||||
|
imports.MapPost("/upload", (IFormFile file, ImportService service) =>
|
||||||
|
{
|
||||||
|
if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new ArgumentException("Only .zip files can be uploaded.", nameof(file));
|
||||||
|
|
||||||
|
if (file.Length == 0)
|
||||||
|
throw new ArgumentException("The uploaded file is empty.", nameof(file));
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
return Results.Ok(service.UploadZip(stream, file.FileName));
|
||||||
|
})
|
||||||
|
.DisableAntiforgery()
|
||||||
|
.WithSummary("Upload a zip of an outline folder and stage it under the configured import root.");
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Novelly.Api.Imports;
|
||||||
|
|
||||||
|
public class ImportOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Imports";
|
||||||
|
|
||||||
|
public string? RootPath { get; set; }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public record ImportLedger(
|
|||||||
internal static class ImportPaths
|
internal static class ImportPaths
|
||||||
{
|
{
|
||||||
private const string LedgerFileName = ".novelly-import.json";
|
private const string LedgerFileName = ".novelly-import.json";
|
||||||
|
public const string StagingFolderName = ".novelly-staging";
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions LedgerOptions = new()
|
private static readonly JsonSerializerOptions LedgerOptions = new()
|
||||||
{
|
{
|
||||||
@@ -20,7 +21,7 @@ internal static class ImportPaths
|
|||||||
WriteIndented = true
|
WriteIndented = true
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string ResolveRoot(string sourceRoot)
|
public static string ResolveRoot(string sourceRoot, string? importRoot = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(sourceRoot))
|
if (string.IsNullOrWhiteSpace(sourceRoot))
|
||||||
throw new ArgumentException("'Source Root' must not be empty.", nameof(sourceRoot));
|
throw new ArgumentException("'Source Root' must not be empty.", nameof(sourceRoot));
|
||||||
@@ -38,25 +39,57 @@ internal static class ImportPaths
|
|||||||
if (!Directory.Exists(full))
|
if (!Directory.Exists(full))
|
||||||
throw new ArgumentException($"'{full}' does not exist or is not a directory.", nameof(sourceRoot));
|
throw new ArgumentException($"'{full}' does not exist or is not a directory.", nameof(sourceRoot));
|
||||||
|
|
||||||
|
EnsureWithinImportRoot(importRoot, full, sourceRoot);
|
||||||
|
|
||||||
return full;
|
return full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void EnsureWithinImportRoot(string? importRoot, string candidate, string originalInput)
|
||||||
|
{
|
||||||
|
if (importRoot is not null && !IsWithin(importRoot, candidate))
|
||||||
|
throw new ArgumentException($"'{originalInput}' is outside the configured import root.", nameof(originalInput));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsSingleFileSource(string root)
|
||||||
|
{
|
||||||
|
if (Directory.EnumerateDirectories(root).Any())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Directory.EnumerateFiles(root, "*.md", SearchOption.TopDirectoryOnly).Count() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
public static string ResolveWithin(string root, string relativePath)
|
public static string ResolveWithin(string root, string relativePath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(relativePath))
|
if (string.IsNullOrWhiteSpace(relativePath))
|
||||||
throw new ArgumentException("Path must not be empty.");
|
throw new ArgumentException("Path must not be empty.");
|
||||||
|
|
||||||
var combined = Path.GetFullPath(Path.Combine(root, relativePath));
|
var combined = Path.GetFullPath(Path.Combine(root, relativePath));
|
||||||
var relativeToRoot = Path.GetRelativePath(root, combined);
|
|
||||||
|
|
||||||
if (relativeToRoot.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativeToRoot))
|
if (!IsWithin(root, combined))
|
||||||
throw new ArgumentException($"'{relativePath}' escapes the import source folder.");
|
throw new ArgumentException($"'{relativePath}' escapes the import source folder.");
|
||||||
|
|
||||||
return combined;
|
return combined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsWithin(string root, string candidate)
|
||||||
|
{
|
||||||
|
var relativeToRoot = Path.GetRelativePath(root, candidate);
|
||||||
|
return relativeToRoot == "." || !relativeToRoot.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativeToRoot);
|
||||||
|
}
|
||||||
|
|
||||||
public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName);
|
public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName);
|
||||||
|
|
||||||
|
public static string StagingRoot(string importRoot) => Path.Combine(importRoot, StagingFolderName);
|
||||||
|
|
||||||
|
public static string SanitizeForFolderName(string value)
|
||||||
|
{
|
||||||
|
var sanitized = new string(value.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray());
|
||||||
|
sanitized = sanitized.Trim('-', '_');
|
||||||
|
return string.IsNullOrEmpty(sanitized) ? "import" : sanitized[..Math.Min(sanitized.Length, 60)];
|
||||||
|
}
|
||||||
|
|
||||||
public static ImportLedger? ReadLedger(string root)
|
public static ImportLedger? ReadLedger(string root)
|
||||||
{
|
{
|
||||||
var path = LedgerPath(root);
|
var path = LedgerPath(root);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Data;
|
using Novelly.Api.Data;
|
||||||
@@ -13,10 +14,34 @@ public class ImportService(
|
|||||||
NovelService novels,
|
NovelService novels,
|
||||||
Channel<Guid> queue,
|
Channel<Guid> queue,
|
||||||
INovelUserContext userContext,
|
INovelUserContext userContext,
|
||||||
|
IOptions<ImportOptions> importOptions,
|
||||||
|
ImportZipExtractor zipExtractor,
|
||||||
ILogger<ImportService> logger,
|
ILogger<ImportService> logger,
|
||||||
IModelValidator<InspectImportRequest> inspectValidator,
|
IModelValidator<InspectImportRequest> inspectValidator,
|
||||||
IModelValidator<StartImportRequest> startValidator)
|
IModelValidator<StartImportRequest> startValidator)
|
||||||
{
|
{
|
||||||
|
private readonly string? _importRoot = importOptions.Value.RootPath is { } root ? Path.GetFullPath(root) : null;
|
||||||
|
|
||||||
|
public ImportUploadResponse UploadZip(Stream zipStream, string fileName)
|
||||||
|
{
|
||||||
|
if (_importRoot is null)
|
||||||
|
throw new InvalidOperationException("No import root is configured (Imports:RootPath).");
|
||||||
|
|
||||||
|
logger.LogInformation("Uploading import zip {FileName}", fileName);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_importRoot);
|
||||||
|
var stagingDir = Path.Combine(
|
||||||
|
ImportPaths.StagingRoot(_importRoot),
|
||||||
|
$"zip-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(fileName))}-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
zipExtractor.Extract(zipStream, stagingDir);
|
||||||
|
|
||||||
|
var markdownCount = Directory.EnumerateFiles(stagingDir, "*.md", SearchOption.AllDirectories).Count();
|
||||||
|
var relativePath = Path.GetRelativePath(_importRoot, stagingDir).Replace(Path.DirectorySeparatorChar, '/');
|
||||||
|
|
||||||
|
return new ImportUploadResponse(stagingDir, relativePath, markdownCount);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
|
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Null(request, nameof(request));
|
Guard.Null(request, nameof(request));
|
||||||
@@ -24,7 +49,7 @@ public class ImportService(
|
|||||||
|
|
||||||
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
|
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
|
||||||
|
|
||||||
var root = ImportPaths.ResolveRoot(request.SourceRoot);
|
var root = ResolveSourceRoot(request.SourceRoot);
|
||||||
var ledger = ImportPaths.ReadLedger(root);
|
var ledger = ImportPaths.ReadLedger(root);
|
||||||
var total = ImportPaths.CountChapterFiles(root);
|
var total = ImportPaths.CountChapterFiles(root);
|
||||||
|
|
||||||
@@ -48,7 +73,7 @@ public class ImportService(
|
|||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
|
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
|
||||||
|
|
||||||
var root = ImportPaths.ResolveRoot(request.SourceRoot);
|
var root = ResolveSourceRoot(request.SourceRoot);
|
||||||
|
|
||||||
if (request.ForceRestart)
|
if (request.ForceRestart)
|
||||||
{
|
{
|
||||||
@@ -88,6 +113,40 @@ public class ImportService(
|
|||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string ResolveSourceRoot(string sourceRoot)
|
||||||
|
{
|
||||||
|
string full;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
full = Path.GetFullPath(sourceRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot), ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(full))
|
||||||
|
{
|
||||||
|
return ImportPaths.ResolveRoot(sourceRoot, _importRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!full.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new ArgumentException($"'{sourceRoot}' is not a markdown file or a directory.", nameof(sourceRoot));
|
||||||
|
|
||||||
|
ImportPaths.EnsureWithinImportRoot(_importRoot, full, sourceRoot);
|
||||||
|
|
||||||
|
var stagingParent = _importRoot is not null
|
||||||
|
? ImportPaths.StagingRoot(_importRoot)
|
||||||
|
: Path.Combine(Path.GetTempPath(), "novelly-import-staging");
|
||||||
|
var stagingDir = Path.Combine(
|
||||||
|
stagingParent, $"file-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(full))}");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(stagingDir);
|
||||||
|
File.Copy(full, Path.Combine(stagingDir, Path.GetFileName(full)), overwrite: true);
|
||||||
|
|
||||||
|
return stagingDir;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
|
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Imports;
|
||||||
|
|
||||||
|
public class ImportZipExtractor(ILogger<ImportZipExtractor> logger)
|
||||||
|
{
|
||||||
|
private const int MaxEntryCount = 2000;
|
||||||
|
private const long MaxEntryUncompressedBytes = 10 * 1024 * 1024;
|
||||||
|
private const long MaxTotalUncompressedBytes = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
private static readonly string[] AllowedFileNames = [".novelly-import.json"];
|
||||||
|
|
||||||
|
public void Extract(Stream zipStream, string stagingDirectory)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(stagingDirectory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
|
||||||
|
|
||||||
|
var entries = archive.Entries
|
||||||
|
.Where(e => !string.IsNullOrEmpty(e.Name))
|
||||||
|
.Where(e => !e.FullName.StartsWith("__MACOSX/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(e => AllowedFileNames.Contains(e.Name) || !e.Name.StartsWith('.'))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (entries.Length == 0)
|
||||||
|
throw new ArgumentException("The zip file is empty.");
|
||||||
|
|
||||||
|
if (entries.Length > MaxEntryCount)
|
||||||
|
throw new ArgumentException($"The zip file has too many entries (max {MaxEntryCount}).");
|
||||||
|
|
||||||
|
var stripPrefix = FindCommonTopLevelDirectory(entries);
|
||||||
|
var totalBytes = 0L;
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var relativePath = stripPrefix is null
|
||||||
|
? entry.FullName
|
||||||
|
: entry.FullName[(stripPrefix.Length + 1)..];
|
||||||
|
|
||||||
|
if (relativePath.Length == 0) continue;
|
||||||
|
|
||||||
|
if (!relativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) && !AllowedFileNames.Contains(entry.Name))
|
||||||
|
throw new ArgumentException($"'{entry.FullName}' is not a markdown file. Only .md files (and .novelly-import.json) are allowed.");
|
||||||
|
|
||||||
|
if (entry.Length > MaxEntryUncompressedBytes)
|
||||||
|
throw new ArgumentException($"'{entry.FullName}' is too large (max {MaxEntryUncompressedBytes / (1024 * 1024)} MB per file).");
|
||||||
|
|
||||||
|
totalBytes += entry.Length;
|
||||||
|
if (totalBytes > MaxTotalUncompressedBytes)
|
||||||
|
throw new ArgumentException($"The zip file is too large uncompressed (max {MaxTotalUncompressedBytes / (1024 * 1024)} MB).");
|
||||||
|
|
||||||
|
var destination = ImportPaths.ResolveWithin(stagingDirectory, relativePath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||||
|
|
||||||
|
using var entryStream = entry.Open();
|
||||||
|
using var fileStream = File.Create(destination);
|
||||||
|
entryStream.CopyTo(fileStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Extracted import zip with {EntryCount} entries into staging folder", entries.Length);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (Directory.Exists(stagingDirectory))
|
||||||
|
Directory.Delete(stagingDirectory, recursive: true);
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindCommonTopLevelDirectory(IReadOnlyCollection<ZipArchiveEntry> entries)
|
||||||
|
{
|
||||||
|
var topLevelSegments = entries
|
||||||
|
.Select(e => e.FullName.Split('/', '\\')[0])
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return topLevelSegments.Length == 1 && entries.All(e => e.FullName.Contains('/') || e.FullName.Contains('\\'))
|
||||||
|
? topLevelSegments[0]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Novels;
|
using Novelly.Api.Novels;
|
||||||
|
|
||||||
namespace Novelly.Api.Locations;
|
namespace Novelly.Api.Locations;
|
||||||
|
|
||||||
public class Location
|
public class Location : ISoftDeletable
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ public class Location
|
|||||||
public List<Chapter> Chapters { get; set; } = [];
|
public List<Chapter> Chapters { get; set; } = [];
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
|
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
|
||||||
@@ -25,7 +27,8 @@ public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location
|
|||||||
{
|
{
|
||||||
entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
|
entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
|
||||||
|
|
||||||
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique();
|
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique().HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
entity.HasQueryFilter(l => l.DeletedAt == null);
|
||||||
|
|
||||||
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
|
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
|
||||||
.UsingEntity(join => join.ToTable("ChapterLocations"));
|
.UsingEntity(join => join.ToTable("ChapterLocations"));
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public static class LocationEndpoints
|
|||||||
|
|
||||||
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
|
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
|
||||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||||
.WithSummary("Delete a location. Whatever carried it is left alone.");
|
.WithSummary("Move a location to the trash. Whatever carried it is left alone.");
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ public class LocationService(
|
|||||||
{
|
{
|
||||||
Guard.Default(locationId, nameof(locationId));
|
Guard.Default(locationId, nameof(locationId));
|
||||||
|
|
||||||
logger.LogInformation("Deleting location {LocationId}", locationId);
|
logger.LogInformation("Moving location {LocationId} to trash", locationId);
|
||||||
|
|
||||||
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
|
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
|
||||||
if (location is null)
|
if (location is null)
|
||||||
@@ -133,7 +133,7 @@ public class LocationService(
|
|||||||
|
|
||||||
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.Locations.Remove(location);
|
location.DeletedAt = DateTimeOffset.UtcNow;
|
||||||
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id);
|
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Novelly.Api.Mcp;
|
||||||
|
|
||||||
|
public static class McpEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapNovelMcp(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapMcp("/mcp");
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using Novelly.Api.Agent;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Mcp;
|
||||||
|
|
||||||
|
public static class NovelMcpTools
|
||||||
|
{
|
||||||
|
private const string NovelIdProperty = "novelId";
|
||||||
|
|
||||||
|
public static IReadOnlyList<Tool> Describe(IReadOnlyList<AgentToolDefinition> definitions) =>
|
||||||
|
[.. definitions.Select(definition => new Tool
|
||||||
|
{
|
||||||
|
Name = definition.Name,
|
||||||
|
Description = definition.Description,
|
||||||
|
InputSchema = definition.RequiresNovelId ? WithNovelId(definition.InputSchema) : definition.InputSchema
|
||||||
|
})];
|
||||||
|
|
||||||
|
public static async Task<CallToolResult> CallAsync(
|
||||||
|
NovelAgentToolset toolset,
|
||||||
|
IReadOnlyList<AgentToolDefinition> definitions,
|
||||||
|
CallToolRequestParams parameters,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var definition = definitions.FirstOrDefault(d => d.Name == parameters.Name);
|
||||||
|
if (definition is null)
|
||||||
|
{
|
||||||
|
return new CallToolResult
|
||||||
|
{
|
||||||
|
IsError = true,
|
||||||
|
Content = [new TextContentBlock { Text = $"No such tool: '{parameters.Name}'." }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var arguments = ToJsonElement(parameters.Arguments);
|
||||||
|
|
||||||
|
Guid novelId;
|
||||||
|
if (definition.RequiresNovelId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
novelId = JsonInput.RequiredGuid(arguments, NovelIdProperty);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = ex.Message }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
novelId = Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await toolset.ExecuteAsync(parameters.Name, novelId, arguments, ct);
|
||||||
|
|
||||||
|
return new CallToolResult
|
||||||
|
{
|
||||||
|
IsError = result.IsError,
|
||||||
|
Content = [new TextContentBlock { Text = result.Content }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement ToJsonElement(IDictionary<string, JsonElement>? arguments)
|
||||||
|
{
|
||||||
|
if (arguments is null)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<JsonElement>("{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = new JsonObject();
|
||||||
|
foreach (var (key, value) in arguments)
|
||||||
|
{
|
||||||
|
obj[key] = JsonNode.Parse(value.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<JsonElement>(obj.ToJsonString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement WithNovelId(JsonElement schema)
|
||||||
|
{
|
||||||
|
var node = JsonNode.Parse(schema.GetRawText())!.AsObject();
|
||||||
|
var properties = new JsonObject
|
||||||
|
{
|
||||||
|
[NovelIdProperty] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "string",
|
||||||
|
["description"] = "The novel's id."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node["properties"] is JsonObject existingProperties)
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in existingProperties.ToList())
|
||||||
|
{
|
||||||
|
existingProperties.Remove(key);
|
||||||
|
properties[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node["properties"] = properties;
|
||||||
|
|
||||||
|
var required = node["required"] as JsonArray ?? [];
|
||||||
|
required.Insert(0, NovelIdProperty);
|
||||||
|
node["required"] = required;
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<JsonElement>(node.ToJsonString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.1.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ using Novelly.Api.Data;
|
|||||||
using Novelly.Api.Genres;
|
using Novelly.Api.Genres;
|
||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Locations;
|
using Novelly.Api.Locations;
|
||||||
|
using Novelly.Api.Mcp;
|
||||||
using Novelly.Api.Novels;
|
using Novelly.Api.Novels;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
using Novelly.Api.Trash;
|
||||||
using Novelly.Api.Users;
|
using Novelly.Api.Users;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
@@ -68,6 +70,12 @@ using (var scope = app.Services.CreateScope())
|
|||||||
|
|
||||||
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
|
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
|
||||||
await ActivityBackfill.RunAsync(db, app.Logger);
|
await ActivityBackfill.RunAsync(db, app.Logger);
|
||||||
|
|
||||||
|
var importRoot = builder.Configuration.GetSection(ImportOptions.SectionName)[nameof(ImportOptions.RootPath)];
|
||||||
|
if (!string.IsNullOrWhiteSpace(importRoot))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(importRoot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
@@ -105,6 +113,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
|
app.MapNovelMcp();
|
||||||
|
|
||||||
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
||||||
app.MapUiSettingsEndpoints();
|
app.MapUiSettingsEndpoints();
|
||||||
@@ -122,7 +131,8 @@ app.MapNovelEndpoints()
|
|||||||
.MapOpenQuestionEndpoints()
|
.MapOpenQuestionEndpoints()
|
||||||
.MapAgentEndpoints()
|
.MapAgentEndpoints()
|
||||||
.MapImportEndpoints()
|
.MapImportEndpoints()
|
||||||
.MapActivityEndpoints();
|
.MapActivityEndpoints()
|
||||||
|
.MapTrashEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -76,22 +76,28 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQu
|
|||||||
|
|
||||||
public static class OpenQuestionMapping
|
public static class OpenQuestionMapping
|
||||||
{
|
{
|
||||||
public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
|
public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary<Guid, int>? displayNumbers = null)
|
||||||
|
{
|
||||||
|
var chapter = q.Chapter is { DeletedAt: null } ? q.Chapter : null;
|
||||||
|
var character = q.Character is { DeletedAt: null } ? q.Character : null;
|
||||||
|
|
||||||
|
return new(
|
||||||
q.Id,
|
q.Id,
|
||||||
q.NovelId,
|
q.NovelId,
|
||||||
q.Question,
|
q.Question,
|
||||||
q.Detail,
|
q.Detail,
|
||||||
q.ChapterId,
|
q.ChapterId,
|
||||||
q.Chapter?.Number,
|
chapter?.Number,
|
||||||
q.Chapter?.Title,
|
chapter?.Title,
|
||||||
q.Chapter is { } chapter
|
chapter is not null
|
||||||
? ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title)
|
? ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title)
|
||||||
: null,
|
: null,
|
||||||
q.CharacterId,
|
q.CharacterId,
|
||||||
q.Character?.Name,
|
character?.Name,
|
||||||
q.Resolution,
|
q.Resolution,
|
||||||
q.IsResolved,
|
q.IsResolved,
|
||||||
q.ResolvedAt,
|
q.ResolvedAt,
|
||||||
q.CreatedAt,
|
q.CreatedAt,
|
||||||
q.UpdatedAt);
|
q.UpdatedAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
public record TrashedItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
TrashEntityKind Kind,
|
||||||
|
string Label,
|
||||||
|
string? Detail,
|
||||||
|
DateTimeOffset DeletedAt,
|
||||||
|
DateTimeOffset PurgeAfter);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Novelly.Api.Common;
|
||||||
|
using Novelly.Api.Common.Validation;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
public static class TrashEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapTrashEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/trash").WithTags("Trash")
|
||||||
|
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||||
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
|
novelScoped.MapGet("/", async (Guid novelId, TrashService service, CancellationToken ct) =>
|
||||||
|
Results.Ok(await service.ListAsync(novelId, ct)))
|
||||||
|
.WithSummary("List everything in a novel's trash.");
|
||||||
|
|
||||||
|
novelScoped.MapDelete("/", async (Guid novelId, TrashService service, CancellationToken ct) =>
|
||||||
|
Results.Ok(new { purged = await service.EmptyAsync(novelId, ct) }))
|
||||||
|
.WithSummary("Empty a novel's trash, permanently deleting everything in it.");
|
||||||
|
|
||||||
|
var trash = app.MapGroup("/api/trash").WithTags("Trash")
|
||||||
|
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||||
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
|
trash.MapPost("/{kind}/{id:guid}/restore", async (
|
||||||
|
TrashEntityKind kind, Guid id, TrashService service, CancellationToken ct) =>
|
||||||
|
await service.RestoreAsync(kind, id, ct) ? Results.NoContent() : Results.NotFound())
|
||||||
|
.WithSummary("Restore a trashed item.");
|
||||||
|
|
||||||
|
trash.MapDelete("/{kind}/{id:guid}", async (
|
||||||
|
TrashEntityKind kind, Guid id, TrashService service, CancellationToken ct) =>
|
||||||
|
await service.PurgeAsync(kind, id, ct) ? Results.NoContent() : Results.NotFound())
|
||||||
|
.WithSummary("Permanently delete a trashed item.");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
public enum TrashEntityKind
|
||||||
|
{
|
||||||
|
Character,
|
||||||
|
Chapter,
|
||||||
|
Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
public class TrashOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Trash";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public int RetentionDays { get; set; } = 30;
|
||||||
|
public TimeOnly PurgeAtLocalTime { get; set; } = new(2, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Data;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
internal static class TrashPurge
|
||||||
|
{
|
||||||
|
public static async Task<bool> RemoveAsync(INovelDbContext db, TrashEntityKind kind, Guid id, CancellationToken ct) => kind switch
|
||||||
|
{
|
||||||
|
TrashEntityKind.Character => await RemoveCharacterAsync(db, id, ct),
|
||||||
|
TrashEntityKind.Chapter => await RemoveChapterAsync(db, id, ct),
|
||||||
|
TrashEntityKind.Location => await RemoveLocationAsync(db, id, ct),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<bool> RemoveCharacterAsync(INovelDbContext db, Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var character = await db.Characters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inboundRelationships = await db.CharacterRelationships
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(r => r.RelatedCharacterId == id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
db.CharacterRelationships.RemoveRange(inboundRelationships);
|
||||||
|
db.Characters.Remove(character);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> RemoveChapterAsync(INovelDbContext db, Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var chapter = await db.Chapters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Chapters.Remove(chapter);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> RemoveLocationAsync(INovelDbContext db, Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var location = await db.Locations.IgnoreQueryFilters().FirstOrDefaultAsync(l => l.Id == id, ct);
|
||||||
|
if (location is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Locations.Remove(location);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Novelly.Api.Data;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
public class TrashPurgeRunner(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<TrashOptions> options,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<TrashPurgeRunner> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!options.Value.Enabled)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Trash purge is disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunPurgePassAsync(stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var now = clock.GetLocalNow();
|
||||||
|
var delay = TrashPurgeSchedule.NextRunAfter(now, options.Value.PurgeAtLocalTime) - now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, clock, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunPurgePassAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunPurgePassAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<INovelDbContext>();
|
||||||
|
|
||||||
|
var cutoff = clock.GetUtcNow().AddDays(-options.Value.RetentionDays);
|
||||||
|
var (characterCount, chapterCount, locationCount) = await SweepAsync(db, cutoff, ct);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Trash purge removed {CharacterCount} characters, {ChapterCount} chapters, {LocationCount} locations",
|
||||||
|
characterCount, chapterCount, locationCount);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Trash purge pass failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(int Characters, int Chapters, int Locations)> SweepAsync(
|
||||||
|
INovelDbContext db, DateTimeOffset cutoff, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var characterIds = await db.Characters.IgnoreQueryFilters()
|
||||||
|
.Where(c => c.DeletedAt != null && c.DeletedAt < cutoff).Select(c => c.Id).ToListAsync(ct);
|
||||||
|
var chapterIds = await db.Chapters.IgnoreQueryFilters()
|
||||||
|
.Where(c => c.DeletedAt != null && c.DeletedAt < cutoff).Select(c => c.Id).ToListAsync(ct);
|
||||||
|
var locationIds = await db.Locations.IgnoreQueryFilters()
|
||||||
|
.Where(l => l.DeletedAt != null && l.DeletedAt < cutoff).Select(l => l.Id).ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var id in characterIds)
|
||||||
|
await TrashPurge.RemoveAsync(db, TrashEntityKind.Character, id, ct);
|
||||||
|
|
||||||
|
foreach (var id in chapterIds)
|
||||||
|
await TrashPurge.RemoveAsync(db, TrashEntityKind.Chapter, id, ct);
|
||||||
|
|
||||||
|
foreach (var id in locationIds)
|
||||||
|
await TrashPurge.RemoveAsync(db, TrashEntityKind.Location, id, ct);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return (characterIds.Count, chapterIds.Count, locationIds.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
public static class TrashPurgeSchedule
|
||||||
|
{
|
||||||
|
public static DateTimeOffset NextRunAfter(DateTimeOffset now, TimeOnly runAt)
|
||||||
|
{
|
||||||
|
var candidate = new DateTimeOffset(now.Year, now.Month, now.Day, runAt.Hour, runAt.Minute, runAt.Second, now.Offset);
|
||||||
|
|
||||||
|
return candidate > now ? candidate : candidate.AddDays(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
|
using Novelly.Api.Common;
|
||||||
|
using Novelly.Api.Data;
|
||||||
|
using Novelly.Api.Users;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Trash;
|
||||||
|
|
||||||
|
public class TrashService(
|
||||||
|
INovelDbContext db,
|
||||||
|
NovelAccessService access,
|
||||||
|
ActivityLog activity,
|
||||||
|
IOptions<TrashOptions> options,
|
||||||
|
ILogger<TrashService> logger)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<TrashedItemResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Guard.Default(novelId, nameof(novelId));
|
||||||
|
|
||||||
|
logger.LogInformation("Listing trash for novel {NovelId}", novelId);
|
||||||
|
|
||||||
|
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
||||||
|
|
||||||
|
var retentionDays = options.Value.RetentionDays;
|
||||||
|
|
||||||
|
var characters = await db.Characters
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(c => c.NovelId == novelId && c.DeletedAt != null)
|
||||||
|
.Select(c => new TrashedItemResponse(
|
||||||
|
c.Id, TrashEntityKind.Character, c.Name,
|
||||||
|
c.ArcStages.Count > 0 ? $"{c.ArcStages.Count} arc stages" : null,
|
||||||
|
c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var chapters = await db.Chapters
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(c => c.NovelId == novelId && c.DeletedAt != null)
|
||||||
|
.Select(c => new TrashedItemResponse(
|
||||||
|
c.Id, TrashEntityKind.Chapter, c.Title,
|
||||||
|
c.Beats.Count > 0 ? $"{c.Beats.Count} beats" : null,
|
||||||
|
c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var locations = await db.Locations
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(l => l.NovelId == novelId && l.DeletedAt != null)
|
||||||
|
.Select(l => new TrashedItemResponse(
|
||||||
|
l.Id, TrashEntityKind.Location, l.Name,
|
||||||
|
l.Chapters.Count > 0 ? $"used by {l.Chapters.Count} chapters" : null,
|
||||||
|
l.DeletedAt!.Value, l.DeletedAt!.Value.AddDays(retentionDays)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return [.. characters.Concat(chapters).Concat(locations).OrderByDescending(i => i.DeletedAt)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RestoreAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Guard.Default(id, nameof(id));
|
||||||
|
|
||||||
|
logger.LogInformation("Restoring {Kind} {ItemId} from trash", kind, id);
|
||||||
|
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
TrashEntityKind.Character => await RestoreCharacterAsync(id, ct),
|
||||||
|
TrashEntityKind.Chapter => await RestoreChapterAsync(id, ct),
|
||||||
|
TrashEntityKind.Location => await RestoreLocationAsync(id, ct),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> PurgeAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Guard.Default(id, nameof(id));
|
||||||
|
|
||||||
|
logger.LogInformation("Purging {Kind} {ItemId} from trash", kind, id);
|
||||||
|
|
||||||
|
var novelId = await NovelIdOfTrashedAsync(kind, id, ct);
|
||||||
|
if (novelId is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await access.RequireAsync(novelId.Value, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
|
var removed = await TrashPurge.RemoveAsync(db, kind, id, ct);
|
||||||
|
if (!removed)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> EmptyAsync(Guid novelId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Guard.Default(novelId, nameof(novelId));
|
||||||
|
|
||||||
|
logger.LogInformation("Emptying trash for novel {NovelId}", novelId);
|
||||||
|
|
||||||
|
await access.RequireAsync(novelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
|
var items = await ListAsync(novelId, ct);
|
||||||
|
|
||||||
|
var purged = 0;
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (await TrashPurge.RemoveAsync(db, item.Kind, item.Id, ct))
|
||||||
|
{
|
||||||
|
purged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
logger.LogInformation("Emptied {Count} items from trash for novel {NovelId}", purged, novelId);
|
||||||
|
return purged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> RestoreCharacterAsync(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var character = await db.Characters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct);
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Trashed character {CharacterId} not found", id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
|
character.DeletedAt = null;
|
||||||
|
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Restored, character.Id);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> RestoreChapterAsync(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var chapter = await db.Chapters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct);
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Trashed chapter {ChapterId} not found", id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
|
chapter.DeletedAt = null;
|
||||||
|
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Restored, chapter.Id, chapter.WordCount);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> RestoreLocationAsync(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var location = await db.Locations.IgnoreQueryFilters().FirstOrDefaultAsync(l => l.Id == id && l.DeletedAt != null, ct);
|
||||||
|
if (location is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Trashed location {LocationId} not found", id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
|
var clash = await db.Locations.FirstOrDefaultAsync(
|
||||||
|
l => l.NovelId == location.NovelId && EF.Functions.Like(l.Name, location.Name), ct);
|
||||||
|
if (clash is not null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Rejected restore of location {LocationId}: '{Name}' already exists as {ClashLocationId}", id, location.Name, clash.Id);
|
||||||
|
throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
location.DeletedAt = null;
|
||||||
|
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Restored, location.Id);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid?> NovelIdOfTrashedAsync(TrashEntityKind kind, Guid id, CancellationToken ct) => kind switch
|
||||||
|
{
|
||||||
|
TrashEntityKind.Character => (await db.Characters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)),
|
||||||
|
TrashEntityKind.Chapter => (await db.Chapters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)),
|
||||||
|
TrashEntityKind.Location => (await db.Locations.IgnoreQueryFilters().Where(l => l.Id == id && l.DeletedAt != null).Select(l => (Guid?)l.NovelId).FirstOrDefaultAsync(ct)),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,5 +15,8 @@
|
|||||||
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
|
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Imports": {
|
||||||
|
"RootPath": "../../imports"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,13 @@
|
|||||||
},
|
},
|
||||||
"UiSettings": {
|
"UiSettings": {
|
||||||
"ShowPronouns": false
|
"ShowPronouns": false
|
||||||
|
},
|
||||||
|
"Imports": {
|
||||||
|
"RootPath": null
|
||||||
|
},
|
||||||
|
"Trash": {
|
||||||
|
"Enabled": true,
|
||||||
|
"RetentionDays": 30,
|
||||||
|
"PurgeAtLocalTime": "02:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp;
|
|
||||||
|
|
||||||
public class NovelApiClient(HttpClient http, ILogger<NovelApiClient> logger)
|
|
||||||
{
|
|
||||||
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> PutAsync(string path, object body, CancellationToken ct = default) =>
|
|
||||||
SendAsync(new HttpRequestMessage(HttpMethod.Put, path)
|
|
||||||
{
|
|
||||||
Content = JsonContent.Create(body, options: Options)
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
|
|
||||||
|
|
||||||
private async Task<CallToolResult> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
HttpResponseMessage response;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
response = await http.SendAsync(request, ct);
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Could not reach the Novelly API at {BaseAddress}", http.BaseAddress);
|
|
||||||
return Error($"Could not reach the Novelly 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.Unauthorized => $"Not permitted: the Novelly API rejected the service api key. Set NOVELLY_API_KEY to match the API's Auth:ServiceApiKey. ({detail})",
|
|
||||||
HttpStatusCode.Forbidden => $"Not permitted: {detail}",
|
|
||||||
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 };
|
|
||||||
|
|
||||||
private string Prettify(string json)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Serialize(JsonSerializer.Deserialize<JsonElement>(json), Options);
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Response body was not valid JSON; returning it unformatted");
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? TryReadProblemDetail(string body)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var problem = JsonSerializer.Deserialize<JsonElement>(body);
|
|
||||||
return problem.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Error response body was not valid JSON problem details");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
</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>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Novelly.Mcp;
|
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
|
||||||
|
|
||||||
builder.Logging.ClearProviders();
|
|
||||||
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
|
|
||||||
builder.Logging.SetMinimumLevel(LogLevel.Warning);
|
|
||||||
|
|
||||||
var apiBaseUrl = builder.Configuration["NOVELLY_API_URL"] ?? "http://localhost:5080";
|
|
||||||
var apiKey = builder.Configuration["NOVELLY_API_KEY"];
|
|
||||||
|
|
||||||
builder.Services.AddHttpClient<NovelApiClient>(client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new Uri(apiBaseUrl);
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(30);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
client.DefaultRequestHeaders.Add("X-Novelly-Api-Key", apiKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services
|
|
||||||
.AddMcpServer()
|
|
||||||
.WithStdioServerTransport()
|
|
||||||
.WithToolsFromAssembly();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp.Tools;
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class BeatTools
|
|
||||||
{
|
|
||||||
[McpServerTool(Name = "get_chapter_outline")]
|
|
||||||
[Description("Read a chapter's outline: its beats in order. Each beat is one row — a short "
|
|
||||||
+ "title, who it belongs to, what happened, and what it sets up. The chapter's "
|
|
||||||
+ "summary paragraph and drafted prose sit on the chapter itself, via get_chapter.")]
|
|
||||||
public static Task<CallToolResult> GetChapterOutline(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/chapters/{chapterId}/beats", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "create_beat")]
|
|
||||||
[Description("Add a beat to a chapter's outline. Keep the title to three to five words — it "
|
|
||||||
+ "is a handle, not a sentence; detail belongs in whatHappened and whatsNext.")]
|
|
||||||
public static Task<CallToolResult> CreateBeat(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
|
||||||
[Description("Three to five words naming the beat.")] string title,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Position in the chapter. Appended to the end when omitted.")] int? sortOrder = null,
|
|
||||||
[Description("Ids of the characters whose beat this is.")] Guid[]? characterIds = null,
|
|
||||||
[Description("The event itself.")] string? whatHappened = null,
|
|
||||||
[Description("What it sets in motion — the hook into the next beat.")] string? whatsNext = null,
|
|
||||||
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
|
|
||||||
api.PostAsync($"/api/chapters/{chapterId}/beats",
|
|
||||||
new { title, sortOrder, characterIds, whatHappened, whatsNext, tags }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "update_beat")]
|
|
||||||
[Description("Revise a beat. Only the fields you supply change. Supplying a characterIds or "
|
|
||||||
+ "tag list replaces the beat's characters or tags outright — pass an empty list "
|
|
||||||
+ "to clear one, and include everything you want to keep.")]
|
|
||||||
public static Task<CallToolResult> UpdateBeat(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The beat's id.")] Guid beatId,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Three to five words naming the beat.")] string? title = null,
|
|
||||||
[Description("Position in the chapter.")] int? sortOrder = null,
|
|
||||||
[Description("Ids of the characters whose beat this is. Replaces the existing list.")] Guid[]? characterIds = null,
|
|
||||||
[Description("The event itself.")] string? whatHappened = null,
|
|
||||||
[Description("What it sets in motion.")] string? whatsNext = null,
|
|
||||||
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
|
|
||||||
api.PatchAsync($"/api/beats/{beatId}",
|
|
||||||
new { title, sortOrder, characterIds, whatHappened, whatsNext, tags }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "delete_beat")]
|
|
||||||
[Description("Remove a beat from a chapter's outline. Confirm with the writer first.")]
|
|
||||||
public static Task<CallToolResult> DeleteBeat(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The beat's id.")] Guid beatId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.DeleteAsync($"/api/beats/{beatId}", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "assign_character_to_beats")]
|
|
||||||
[Description("Add a character to several beats at once. Leaves each beat's existing characters "
|
|
||||||
+ "and other fields alone — this only adds, it never removes.")]
|
|
||||||
public static Task<CallToolResult> AssignCharacterToBeats(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
|
||||||
[Description("Id of the character to add.")] Guid characterId,
|
|
||||||
[Description("Ids of the beats to add the character to.")] Guid[] beatIds,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PostAsync($"/api/chapters/{chapterId}/beats/assign-character", new { characterId, beatIds }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "reorder_beats")]
|
|
||||||
[Description("Renumber a chapter's beats to match the order given. List every beat id in the "
|
|
||||||
+ "order wanted; any left out keep their relative position at the end.")]
|
|
||||||
public static Task<CallToolResult> ReorderBeats(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
|
||||||
[Description("Beat ids in their new order.")] Guid[] beatIds,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PostAsync($"/api/chapters/{chapterId}/beats/reorder", new { beatIds }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "move_beats")]
|
|
||||||
[Description("Move one or more beats from one chapter to another, appending them to the "
|
|
||||||
+ "target chapter's end in the order given.")]
|
|
||||||
public static Task<CallToolResult> MoveBeats(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The beats' current chapter id.")] Guid chapterId,
|
|
||||||
[Description("Id of the chapter to move the beats into.")] Guid targetChapterId,
|
|
||||||
[Description("Ids of the beats to move.")] Guid[] beatIds,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PostAsync($"/api/chapters/{chapterId}/beats/move", new { targetChapterId, beatIds }, ct);
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp.Tools;
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class CharacterTools
|
|
||||||
{
|
|
||||||
[McpServerTool(Name = "list_characters")]
|
|
||||||
[Description("List a novel's character dossiers in full, including their relationships.")]
|
|
||||||
public static Task<CallToolResult> ListCharacters(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/novels/{novelId}/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 novel. 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 novel's id.")] Guid novelId,
|
|
||||||
[Description("The character's name.")] string name,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
|
|
||||||
string? role = null,
|
|
||||||
[Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
|
|
||||||
string? importance = 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, weighed against what they actually need.")] string? motivation = null,
|
|
||||||
[Description("The war inside them and what in the world opposes them.")] string? conflict = null,
|
|
||||||
[Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null,
|
|
||||||
[Description("Anything else worth recording.")] string? notes = null,
|
|
||||||
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null,
|
|
||||||
[Description("Other names this character is known by.")] string[]? aliases = null) =>
|
|
||||||
api.PostAsync($"/api/novels/{novelId}/characters", new
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
role = role ?? "Supporting",
|
|
||||||
importance = importance ?? "Supporting",
|
|
||||||
age,
|
|
||||||
pronouns,
|
|
||||||
occupation,
|
|
||||||
appearance,
|
|
||||||
personality,
|
|
||||||
backstory,
|
|
||||||
motivation,
|
|
||||||
conflict,
|
|
||||||
voice,
|
|
||||||
notes,
|
|
||||||
tags,
|
|
||||||
aliases
|
|
||||||
}, 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("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
|
|
||||||
string? importance = 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, weighed against what they actually need.")] string? motivation = null,
|
|
||||||
[Description("The war inside them and what in the world opposes them.")] string? conflict = null,
|
|
||||||
[Description("Speech patterns and register.")] string? voice = null,
|
|
||||||
[Description("Anything else worth recording.")] string? notes = null,
|
|
||||||
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null,
|
|
||||||
[Description("Other names this character is known by. Replaces the existing aliases.")] string[]? aliases = null) =>
|
|
||||||
api.PatchAsync($"/api/characters/{characterId}", new
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
role,
|
|
||||||
importance,
|
|
||||||
age,
|
|
||||||
pronouns,
|
|
||||||
occupation,
|
|
||||||
appearance,
|
|
||||||
personality,
|
|
||||||
backstory,
|
|
||||||
motivation,
|
|
||||||
conflict,
|
|
||||||
voice,
|
|
||||||
notes,
|
|
||||||
tags,
|
|
||||||
aliases
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "get_character_beats")]
|
|
||||||
[Description("Every beat this character appears in, across the whole book, in manuscript order. "
|
|
||||||
+ "This is what the character actually does on the page, as opposed to what the "
|
|
||||||
+ "dossier claims about them — read it before revising a character.")]
|
|
||||||
public static Task<CallToolResult> GetCharacterBeats(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The character's id.")] Guid characterId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/characters/{characterId}/beats", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "get_character_arc")]
|
|
||||||
[Description("Read a main character's arc: the ordered stages of how they change, each "
|
|
||||||
+ "optionally pinned to the chapter where it lands.")]
|
|
||||||
public static Task<CallToolResult> GetCharacterArc(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The character's id.")] Guid characterId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/characters/{characterId}/arc", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "add_arc_stage")]
|
|
||||||
[Description("Add a stage to a character's arc. Arcs are kept for main characters — promote "
|
|
||||||
+ "the character with update_character first if they are still Supporting.")]
|
|
||||||
public static Task<CallToolResult> AddArcStage(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("Id of the character whose arc to add to.")] Guid characterId,
|
|
||||||
[Description("A short handle for the change, three to five words.")] string title,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("What this stage of the arc results in for the character — what shifts, and what it costs them.")] string? result = null,
|
|
||||||
[Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null,
|
|
||||||
[Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
|
|
||||||
api.PostAsync($"/api/characters/{characterId}/arc",
|
|
||||||
new { title, sortOrder, result, chapterId }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "update_arc_stage")]
|
|
||||||
[Description("Revise a stage of a character's arc. Only the fields you supply change.")]
|
|
||||||
public static Task<CallToolResult> UpdateArcStage(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The arc stage's id.")] Guid arcStageId,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("New title for the stage.")] string? title = null,
|
|
||||||
[Description("What this stage of the arc results in for the character.")] string? result = null,
|
|
||||||
[Description("Id of the chapter where this stage lands.")] Guid? chapterId = null,
|
|
||||||
[Description("Position in the arc.")] int? sortOrder = null) =>
|
|
||||||
api.PatchAsync($"/api/arc-stages/{arcStageId}",
|
|
||||||
new { title, sortOrder, result, chapterId }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "delete_arc_stage")]
|
|
||||||
[Description("Remove a stage from a character's arc.")]
|
|
||||||
public static Task<CallToolResult> DeleteArcStage(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The arc stage's id.")] Guid arcStageId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.DeleteAsync($"/api/arc-stages/{arcStageId}", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "reorder_arc_stages")]
|
|
||||||
[Description("Renumber a character's arc to match the order given. Stages left out keep their "
|
|
||||||
+ "relative position after the ones listed.")]
|
|
||||||
public static Task<CallToolResult> ReorderArcStages(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("Id of the character whose arc to reorder.")] Guid characterId,
|
|
||||||
[Description("Arc stage ids in the order wanted.")] string[] stageIds,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "set_arc_stage_beats")]
|
|
||||||
[Description("Set which beats belong to an arc stage, replacing its current set. This groups the "
|
|
||||||
+ "chapter-level beats that establish or pay off this stage of the character's arc. A "
|
|
||||||
+ "beat moved into this stage leaves any other stage of the same character it was in. "
|
|
||||||
+ "Each beat must already include this character.")]
|
|
||||||
public static Task<CallToolResult> SetArcStageBeats(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The arc stage's id.")] Guid arcStageId,
|
|
||||||
[Description("Beat ids that belong to this stage, replacing whatever was there before.")] string[] beatIds,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "relate_characters")]
|
|
||||||
[Description("Record a relationship between two characters in the same novel. Creates both directions "
|
|
||||||
+ "at once — characterId's side and relatedCharacterId's side — so the pair always shows up "
|
|
||||||
+ "on both dossiers.")]
|
|
||||||
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 characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("How relatedCharacterId is related back to characterId, if different — e.g. 'brother' for "
|
|
||||||
+ "'sister'. Defaults to relationshipType when the relation is symmetric, like 'rival'.")]
|
|
||||||
string? reciprocalRelationshipType = null,
|
|
||||||
[Description("What the relationship is like, and where it is headed.")] string? description = null) =>
|
|
||||||
api.PostAsync($"/api/characters/{characterId}/relationships",
|
|
||||||
new { relatedCharacterId, relationshipType, reciprocalRelationshipType, description }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "link_character_identity")]
|
|
||||||
[Description("Record that this character is really another character — e.g. a character introduced "
|
|
||||||
+ "under one name who is later revealed to be a character already in the novel under "
|
|
||||||
+ "another name. Both characters keep their own dossier and beats; the canonical identity "
|
|
||||||
+ "is whichever character you link to.")]
|
|
||||||
public static Task<CallToolResult> LinkCharacterIdentity(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("Id of the character being revealed as someone else.")] Guid characterId,
|
|
||||||
[Description("Id of the character this one really is.")] Guid sameCharacterAsId,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Id of the chapter where the reveal happens, if any.")] Guid? revealedInChapterId = null,
|
|
||||||
[Description("Context on the reveal, e.g. how and why the disguise held.")] string? note = null) =>
|
|
||||||
api.PutAsync($"/api/characters/{characterId}/identity",
|
|
||||||
new { sameCharacterAsId, revealedInChapterId, note }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "unlink_character_identity")]
|
|
||||||
[Description("Remove a character's identity link, restoring it to its own separate identity.")]
|
|
||||||
public static Task<CallToolResult> UnlinkCharacterIdentity(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The character's id.")] Guid characterId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.DeleteAsync($"/api/characters/{characterId}/identity", ct);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp.Tools;
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class LocationTools
|
|
||||||
{
|
|
||||||
[McpServerTool(Name = "list_locations")]
|
|
||||||
[Description("List a novel's locations with how many chapters are set there. "
|
|
||||||
+ "Read this before inventing a new location so you reuse the writer's vocabulary.")]
|
|
||||||
public static Task<CallToolResult> ListLocations(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/novels/{novelId}/locations", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "get_location_references")]
|
|
||||||
[Description("Cross-reference a location: every chapter set there.")]
|
|
||||||
public static Task<CallToolResult> GetLocationReferences(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The location's id.")] Guid locationId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/locations/{locationId}/references", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "create_location")]
|
|
||||||
[Description("Create a location explicitly. Applying an unknown location by name to a chapter "
|
|
||||||
+ "also creates it, so this is only needed to set one up ahead of time.")]
|
|
||||||
public static Task<CallToolResult> CreateLocation(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
[Description("The location's name. Unique within the novel, matched case-insensitively.")] string name,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PostAsync($"/api/novels/{novelId}/locations", new { name }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "update_location")]
|
|
||||||
[Description("Rename a location. Renaming updates it everywhere it is applied.")]
|
|
||||||
public static Task<CallToolResult> UpdateLocation(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The location's id.")] Guid locationId,
|
|
||||||
[Description("New name.")] string name,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PatchAsync($"/api/locations/{locationId}", new { name }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "delete_location")]
|
|
||||||
[Description("Delete a location. Whatever carried it is left alone — only the label goes.")]
|
|
||||||
public static Task<CallToolResult> DeleteLocation(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The location's id.")] Guid locationId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.DeleteAsync($"/api/locations/{locationId}", ct);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp.Tools;
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class ManuscriptTools
|
|
||||||
{
|
|
||||||
[McpServerTool(Name = "list_chapters")]
|
|
||||||
[Description("List a novel's chapters in manuscript order, with beat and word counts.")]
|
|
||||||
public static Task<CallToolResult> ListChapters(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/novels/{novelId}/chapters", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "get_chapter")]
|
|
||||||
[Description("Read one chapter in full: its outline (beats) and its 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 novel. It goes at the end of the manuscript unless you supply a number. "
|
|
||||||
+ "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.")]
|
|
||||||
public static Task<CallToolResult> CreateChapter(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
[Description("Chapter title.")] string title,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null,
|
|
||||||
[Description("FrontMatter, Body, or BackMatter. Defaults to Body.")] string? kind = null,
|
|
||||||
[Description("The chapter's outline summary paragraph.")] string? summary = null,
|
|
||||||
[Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null,
|
|
||||||
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
|
||||||
[Description("Target length in words.")] int? targetWordCount = null,
|
|
||||||
[Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null,
|
|
||||||
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
|
|
||||||
api.PostAsync($"/api/novels/{novelId}/chapters", new
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
number,
|
|
||||||
kind = kind ?? "Body",
|
|
||||||
summary,
|
|
||||||
locations,
|
|
||||||
status = status ?? "Planned",
|
|
||||||
targetWordCount,
|
|
||||||
prose,
|
|
||||||
tags
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "update_chapter")]
|
|
||||||
[Description("Revise a chapter's title, number, kind, summary, locations, notes, status "
|
|
||||||
+ "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
|
|
||||||
+ "markdown; the word count is recomputed automatically.")]
|
|
||||||
public static Task<CallToolResult> UpdateChapter(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("New title.")] string? title = null,
|
|
||||||
[Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null,
|
|
||||||
[Description("FrontMatter, Body, or BackMatter.")] string? kind = null,
|
|
||||||
[Description("The chapter's outline summary paragraph.")] string? summary = null,
|
|
||||||
[Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = 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,
|
|
||||||
[Description("The chapter's drafted text, in markdown.")] string? prose = null,
|
|
||||||
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
|
|
||||||
api.PatchAsync($"/api/chapters/{chapterId}",
|
|
||||||
new { title, number, kind, summary, locations, notes, status, targetWordCount, prose, tags }, ct);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp.Tools;
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class NovelTools
|
|
||||||
{
|
|
||||||
[McpServerTool(Name = "list_novels")]
|
|
||||||
[Description("List every novel, with counts of characters, chapters and drafted words. "
|
|
||||||
+ "Start here to find the novel id everything else needs.")]
|
|
||||||
public static Task<CallToolResult> ListNovels(NovelApiClient api, CancellationToken ct) =>
|
|
||||||
api.GetAsync("/api/novels", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "get_novel_brief")]
|
|
||||||
[Description("Read a novel's title, author, genre, logline, synopsis, notes and word-count target.")]
|
|
||||||
public static Task<CallToolResult> GetNovel(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/novels/{novelId}", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "create_novel")]
|
|
||||||
[Description("Create a new novel.")]
|
|
||||||
public static Task<CallToolResult> CreateNovel(
|
|
||||||
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("Free-form notes on theme, tone, comparable titles.")] string? notes = null,
|
|
||||||
[Description("Target manuscript length in words.")] int? targetWordCount = null) =>
|
|
||||||
api.PostAsync("/api/novels", new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "update_novel_brief")]
|
|
||||||
[Description("Revise a novel's top-level fields. Only the fields you supply change; "
|
|
||||||
+ "pass an empty string to clear one.")]
|
|
||||||
public static Task<CallToolResult> UpdateNovel(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
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/novels/{novelId}",
|
|
||||||
new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp.Tools;
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class QuestionTools
|
|
||||||
{
|
|
||||||
[McpServerTool(Name = "list_open_questions")]
|
|
||||||
[Description("The decisions the writer has not made yet, newest first. Read this before "
|
|
||||||
+ "proposing changes — an open question marks somewhere the writer is still "
|
|
||||||
+ "thinking, not a gap to fill in for them.")]
|
|
||||||
public static Task<CallToolResult> ListOpenQuestions(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
|
|
||||||
[Description("Narrow to questions about one character.")] Guid? characterId = null,
|
|
||||||
[Description("Include questions already settled. Defaults to false.")] bool includeResolved = false)
|
|
||||||
{
|
|
||||||
var query = new List<string> { $"includeResolved={includeResolved.ToString().ToLowerInvariant()}" };
|
|
||||||
|
|
||||||
if (chapterId is { } chapter)
|
|
||||||
{
|
|
||||||
query.Add($"chapterId={chapter}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (characterId is { } character)
|
|
||||||
{
|
|
||||||
query.Add($"characterId={character}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return api.GetAsync($"/api/novels/{novelId}/questions?{string.Join('&', query)}", ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool(Name = "raise_open_question")]
|
|
||||||
[Description("Record a question the writer has not settled, attached to the chapter outline "
|
|
||||||
+ "and/or the character it is about. Prefer raising a question over guessing.")]
|
|
||||||
public static Task<CallToolResult> RaiseOpenQuestion(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
[Description("The question, in one line.")] string question,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("The thinking around it — options considered, and what each costs.")] string? detail = null,
|
|
||||||
[Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null,
|
|
||||||
[Description("Id of the character this is about, if any.")] Guid? characterId = null) =>
|
|
||||||
api.PostAsync($"/api/novels/{novelId}/questions",
|
|
||||||
new { question, detail, chapterId, characterId }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "update_open_question")]
|
|
||||||
[Description("Revise a question or change what it is attached to. Only the fields you supply change.")]
|
|
||||||
public static Task<CallToolResult> UpdateOpenQuestion(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The question's id.")] Guid questionId,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("New wording for the question.")] string? question = null,
|
|
||||||
[Description("New detail. Pass an empty string to clear it.")] string? detail = null,
|
|
||||||
[Description("Attach to this chapter outline.")] Guid? chapterId = null,
|
|
||||||
[Description("Attach to this character.")] Guid? characterId = null,
|
|
||||||
[Description("Detach from its chapter.")] bool clearChapter = false,
|
|
||||||
[Description("Detach from its character.")] bool clearCharacter = false) =>
|
|
||||||
api.PatchAsync($"/api/questions/{questionId}",
|
|
||||||
new { question, detail, chapterId, characterId, clearChapter, clearCharacter }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "resolve_open_question")]
|
|
||||||
[Description("Settle a question with what the writer decided. Set appendToNotes to also write "
|
|
||||||
+ "the resolution into the notes of the chapter and character it hangs off.")]
|
|
||||||
public static Task<CallToolResult> ResolveOpenQuestion(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The question's id.")] Guid questionId,
|
|
||||||
[Description("What was decided.")] string resolution,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Also append the resolution to the associated notes.")] bool appendToNotes = false) =>
|
|
||||||
api.PostAsync($"/api/questions/{questionId}/resolve", new { resolution, appendToNotes }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "reopen_question")]
|
|
||||||
[Description("Put a resolved question back on the list. Anything already appended to notes stays.")]
|
|
||||||
public static Task<CallToolResult> ReopenQuestion(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The question's id.")] Guid questionId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.PostAsync($"/api/questions/{questionId}/reopen", new { }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "delete_open_question")]
|
|
||||||
[Description("Delete a question outright. Resolving is usually better — it keeps the decision.")]
|
|
||||||
public static Task<CallToolResult> DeleteOpenQuestion(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The question's id.")] Guid questionId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.DeleteAsync($"/api/questions/{questionId}", ct);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using ModelContextProtocol.Protocol;
|
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace Novelly.Mcp.Tools;
|
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class TagTools
|
|
||||||
{
|
|
||||||
[McpServerTool(Name = "list_tags")]
|
|
||||||
[Description("List a novel's tags with how many characters, chapters and beats carry each. "
|
|
||||||
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.")]
|
|
||||||
public static Task<CallToolResult> ListTags(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/novels/{novelId}/tags", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "get_tag_references")]
|
|
||||||
[Description("Cross-reference a tag: every character, chapter and beat carrying it. Use this "
|
|
||||||
+ "to trace a motif, a thread, or a piece of setup through the book.")]
|
|
||||||
public static Task<CallToolResult> GetTagReferences(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The tag's id.")] Guid tagId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.GetAsync($"/api/tags/{tagId}/references", ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "create_tag")]
|
|
||||||
[Description("Create a tag explicitly. Applying an unknown tag by name to a character, "
|
|
||||||
+ "chapter or beat also creates it, so this is only needed to set a colour up front.")]
|
|
||||||
public static Task<CallToolResult> CreateTag(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The novel's id.")] Guid novelId,
|
|
||||||
[Description("The tag's name. Unique within the novel, matched case-insensitively.")] string name,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("Optional hex colour for the UI, e.g. \"#9a4a2f\".")] string? color = null) =>
|
|
||||||
api.PostAsync($"/api/novels/{novelId}/tags", new { name, color }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "update_tag")]
|
|
||||||
[Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")]
|
|
||||||
public static Task<CallToolResult> UpdateTag(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The tag's id.")] Guid tagId,
|
|
||||||
CancellationToken ct,
|
|
||||||
[Description("New name.")] string? name = null,
|
|
||||||
[Description("Hex colour, e.g. \"#9a4a2f\".")] string? color = null) =>
|
|
||||||
api.PatchAsync($"/api/tags/{tagId}", new { name, color }, ct);
|
|
||||||
|
|
||||||
[McpServerTool(Name = "delete_tag")]
|
|
||||||
[Description("Delete a tag. Whatever carried it is left alone — only the label goes.")]
|
|
||||||
public static Task<CallToolResult> DeleteTag(
|
|
||||||
NovelApiClient api,
|
|
||||||
[Description("The tag's id.")] Guid tagId,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
api.DeleteAsync($"/api/tags/{tagId}", ct);
|
|
||||||
}
|
|
||||||
@@ -21,17 +21,12 @@ public static class Extensions
|
|||||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
{
|
{
|
||||||
builder.ConfigureOpenTelemetry();
|
builder.ConfigureOpenTelemetry();
|
||||||
|
|
||||||
builder.AddDefaultHealthChecks();
|
builder.AddDefaultHealthChecks();
|
||||||
|
|
||||||
builder.Services.AddServiceDiscovery();
|
builder.Services.AddServiceDiscovery();
|
||||||
|
|
||||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||||
{
|
{
|
||||||
// Turn on resilience by default
|
|
||||||
http.AddStandardResilienceHandler();
|
http.AddStandardResilienceHandler();
|
||||||
|
|
||||||
// Turn on service discovery by default
|
|
||||||
http.AddServiceDiscovery();
|
http.AddServiceDiscovery();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
client_max_body_size 50m;
|
||||||
proxy_pass http://api:8080/api/;
|
proxy_pass http://api:8080/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
Generated
+36
@@ -8,6 +8,10 @@
|
|||||||
"name": "novelly-web",
|
"name": "novelly-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/fraunces": "^5.3.0",
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/source-serif-4": "^5.3.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
@@ -27,6 +31,38 @@
|
|||||||
"vite": "^8.2.0"
|
"vite": "^8.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/fraunces": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/source-serif-4": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/source-serif-4/-/source-serif-4-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-9vch9WqxjaaA+1o9Ur8pOgIGbCYLjRReOUel23A6lOpD1syptgjtkORevvNmldJ5kGXQL29onQUqI5Ltz0s3bQ==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/jetbrains-mono": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/fraunces": "^5.3.0",
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/source-serif-4": "^5.3.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import TagsPage from './pages/TagsPage'
|
|||||||
import LocationsPage from './pages/LocationsPage'
|
import LocationsPage from './pages/LocationsPage'
|
||||||
import ChaptersPage from './pages/ChaptersPage'
|
import ChaptersPage from './pages/ChaptersPage'
|
||||||
import ChapterPage from './pages/ChapterPage'
|
import ChapterPage from './pages/ChapterPage'
|
||||||
import AgentPage from './pages/AgentPage'
|
import TrashPage from './pages/TrashPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import { AuthProvider, useAuth } from './auth/AuthContext'
|
import { AuthProvider, useAuth } from './auth/AuthContext'
|
||||||
@@ -44,7 +44,7 @@ export default function App() {
|
|||||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||||
<Route path="tags" element={<TagsPage />} />
|
<Route path="tags" element={<TagsPage />} />
|
||||||
<Route path="locations" element={<LocationsPage />} />
|
<Route path="locations" element={<LocationsPage />} />
|
||||||
<Route path="agent" element={<AgentPage />} />
|
<Route path="trash" element={<TrashPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<NovelsPage />} />
|
<Route path="*" element={<NovelsPage />} />
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const isFormData = init?.body instanceof FormData
|
||||||
const response = await fetch(`${BASE}${path}`, {
|
const response = await fetch(`${BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
...init?.headers,
|
...init?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -37,6 +38,7 @@ export const api = {
|
|||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) =>
|
post: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
|
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
|
||||||
|
postForm: <T>(path: string, body: FormData) => request<T>(path, { method: 'POST', body }),
|
||||||
patch: <T>(path: string, body: unknown) =>
|
patch: <T>(path: string, body: unknown) =>
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
put: <T>(path: string, body: unknown) =>
|
put: <T>(path: string, body: unknown) =>
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import type {
|
|||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
Beat,
|
Beat,
|
||||||
Genre,
|
Genre,
|
||||||
|
ImportBrowse,
|
||||||
ImportInspection,
|
ImportInspection,
|
||||||
ImportJob,
|
ImportJob,
|
||||||
ImportJobStatus,
|
ImportJobStatus,
|
||||||
|
ImportUpload,
|
||||||
OpenQuestion,
|
OpenQuestion,
|
||||||
|
Location,
|
||||||
LocationReferences,
|
LocationReferences,
|
||||||
LocationSummary,
|
LocationSummary,
|
||||||
Novel,
|
Novel,
|
||||||
@@ -24,6 +27,8 @@ import type {
|
|||||||
NovelSummary,
|
NovelSummary,
|
||||||
TagReferences,
|
TagReferences,
|
||||||
TagSummary,
|
TagSummary,
|
||||||
|
TrashedItem,
|
||||||
|
TrashEntityKind,
|
||||||
UiSettings,
|
UiSettings,
|
||||||
User,
|
User,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -47,8 +52,10 @@ export const keys = {
|
|||||||
conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
|
conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
|
||||||
conversation: (id: string) => ['conversations', id] as const,
|
conversation: (id: string) => ['conversations', id] as const,
|
||||||
importJob: (id: string) => ['imports', id] as const,
|
importJob: (id: string) => ['imports', id] as const,
|
||||||
|
importBrowse: (path: string) => ['imports', 'browse', path] as const,
|
||||||
novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const,
|
novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const,
|
||||||
myActivity: ['activity'] as const,
|
myActivity: ['activity'] as const,
|
||||||
|
trash: (novelId: string) => ['novels', novelId, 'trash'] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUiSettings = () =>
|
export const useUiSettings = () =>
|
||||||
@@ -199,6 +206,7 @@ export function useDeleteCharacter(novelId: string) {
|
|||||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.myActivity })
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -430,6 +438,14 @@ export const useLocationReferences = (locationId: string | undefined) =>
|
|||||||
enabled: Boolean(locationId),
|
enabled: Boolean(locationId),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function useCreateLocation(novelId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => api.post<Location>(`/api/novels/${novelId}/locations`, { name }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: keys.locations(novelId) }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpdateLocation(novelId: string) {
|
export function useUpdateLocation(novelId: string) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -576,6 +592,7 @@ export function useDeleteChapter(novelId: string) {
|
|||||||
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.myActivity })
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -622,6 +639,46 @@ export const useMyActivity = () =>
|
|||||||
queryFn: () => api.get<ActivityCalendar>('/api/activity'),
|
queryFn: () => api.get<ActivityCalendar>('/api/activity'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const useTrash = (novelId: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: keys.trash(novelId),
|
||||||
|
queryFn: () => api.get<TrashedItem[]>(`/api/novels/${novelId}/trash`),
|
||||||
|
})
|
||||||
|
|
||||||
|
function invalidateAfterTrashChange(qc: ReturnType<typeof useQueryClient>, novelId: string) {
|
||||||
|
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestoreTrashed(novelId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ kind, id }: { kind: TrashEntityKind; id: string }) =>
|
||||||
|
api.post(`/api/trash/${kind}/${id}/restore`, {}),
|
||||||
|
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePurgeTrashed(novelId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ kind, id }: { kind: TrashEntityKind; id: string }) => api.delete(`/api/trash/${kind}/${id}`),
|
||||||
|
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmptyTrash(novelId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.delete(`/api/novels/${novelId}/trash`),
|
||||||
|
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useInspectImport() {
|
export function useInspectImport() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
|
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
|
||||||
@@ -635,6 +692,25 @@ export function useStartImport() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useImportBrowse(path: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.importBrowse(path),
|
||||||
|
queryFn: () => api.get<ImportBrowse>(`/api/imports/browse?path=${encodeURIComponent(path)}`),
|
||||||
|
enabled,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadImportZip() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.postForm<ImportUpload>('/api/imports/upload', form)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused']
|
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused']
|
||||||
|
|
||||||
export function useImportJob(jobId: string | undefined) {
|
export function useImportJob(jobId: string | undefined) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DraftStatus, NovelPhase } from './types'
|
||||||
|
|
||||||
|
const stageVar = (index: 1 | 2 | 3 | 4 | 5) => `var(--stage-${index})`
|
||||||
|
|
||||||
|
const novelPhaseStage: Record<NovelPhase, 1 | 2 | 3 | 4 | 5> = {
|
||||||
|
Brainstorming: 1,
|
||||||
|
Outlining: 2,
|
||||||
|
Writing: 3,
|
||||||
|
Editing: 4,
|
||||||
|
Complete: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftStatusStage: Record<DraftStatus, 1 | 2 | 3 | 4 | 5> = {
|
||||||
|
Planned: 1,
|
||||||
|
Outlined: 2,
|
||||||
|
Drafted: 3,
|
||||||
|
Revised: 4,
|
||||||
|
Final: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function novelPhaseColor(phase: NovelPhase): string {
|
||||||
|
return stageVar(novelPhaseStage[phase])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftStatusColor(status: DraftStatus): string {
|
||||||
|
return stageVar(draftStatusStage[status])
|
||||||
|
}
|
||||||
@@ -329,6 +329,40 @@ export interface ImportInspection {
|
|||||||
completedPasses: string[]
|
completedPasses: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImportBrowseEntry {
|
||||||
|
name: string
|
||||||
|
relativePath: string
|
||||||
|
sourceRoot: string
|
||||||
|
isDirectory: boolean
|
||||||
|
markdownFileCount: number
|
||||||
|
looksImportable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportBrowse {
|
||||||
|
relativePath: string
|
||||||
|
parentRelativePath: string | null
|
||||||
|
entries: ImportBrowseEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUpload {
|
||||||
|
sourceRoot: string
|
||||||
|
relativePath: string
|
||||||
|
markdownFileCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrashEntityKind = 'Character' | 'Chapter' | 'Location'
|
||||||
|
|
||||||
|
export const trashEntityKinds: TrashEntityKind[] = ['Character', 'Chapter', 'Location']
|
||||||
|
|
||||||
|
export interface TrashedItem {
|
||||||
|
id: string
|
||||||
|
kind: TrashEntityKind
|
||||||
|
label: string
|
||||||
|
detail: string | null
|
||||||
|
deletedAt: string
|
||||||
|
purgeAfter: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActivityDay {
|
export interface ActivityDay {
|
||||||
date: string
|
date: string
|
||||||
words: number
|
words: number
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks'
|
||||||
|
import type { AgentMessage } from '../api/types'
|
||||||
|
import { ErrorNote, Spinner } from './ui'
|
||||||
|
import { IconPlus } from './icons'
|
||||||
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
|
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?',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONTEXT_PATTERN = /^Context: (.+)\n\n([\s\S]*)$/
|
||||||
|
|
||||||
|
export interface AgentContext {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPanel({
|
||||||
|
novelId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
context,
|
||||||
|
}: {
|
||||||
|
novelId: string
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
context: AgentContext | null
|
||||||
|
}) {
|
||||||
|
const { data: conversations } = useConversations(novelId)
|
||||||
|
const [conversationId, setConversationId] = useState<string | undefined>()
|
||||||
|
const { data: conversation } = useConversation(conversationId)
|
||||||
|
const send = useSendAgentMessage(novelId)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const endRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [conversation?.messages.length, send.isPending, open])
|
||||||
|
|
||||||
|
const submit = (message: string) => {
|
||||||
|
const trimmed = message.trim()
|
||||||
|
if (!trimmed || send.isPending) return
|
||||||
|
setDraft('')
|
||||||
|
const withContext = context ? `Context: ${context.label}\n\n${trimmed}` : trimmed
|
||||||
|
send.mutate(
|
||||||
|
{ message: withContext, conversationId },
|
||||||
|
{ onSuccess: (turn) => setConversationId(turn.conversationId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useHotkey('mod+Enter', 'Send message', () => submit(draft), {
|
||||||
|
group: 'Agent',
|
||||||
|
allowInInputs: true,
|
||||||
|
enabled: open && draft.trim().length > 0 && !send.isPending,
|
||||||
|
})
|
||||||
|
useHotkey('Escape', 'Close agent', onClose, { group: 'Agent', enabled: open, allowInInputs: true })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="agent-panel"
|
||||||
|
className="fixed top-0 right-0 z-30 flex h-screen w-full flex-col sm:w-[26rem]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderLeft: '1px solid var(--line)',
|
||||||
|
boxShadow: open ? '-12px 0 32px -12px rgba(0,0,0,0.45)' : 'none',
|
||||||
|
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
transition: 'transform 220ms ease',
|
||||||
|
}}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<header className="flex items-center gap-2 px-4 py-3" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
|
<h2 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-display)' }}>
|
||||||
|
Agent
|
||||||
|
</h2>
|
||||||
|
{conversations && conversations.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="input ml-2 w-auto flex-1 py-1 text-xs"
|
||||||
|
value={conversationId ?? ''}
|
||||||
|
onChange={(e) => setConversationId(e.target.value || undefined)}
|
||||||
|
aria-label="Conversation"
|
||||||
|
>
|
||||||
|
<option value="">New conversation</option>
|
||||||
|
{conversations.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn ml-auto px-2 py-1"
|
||||||
|
title="New conversation"
|
||||||
|
onClick={() => setConversationId(undefined)}
|
||||||
|
>
|
||||||
|
<IconPlus width={14} height={14} />
|
||||||
|
</button>
|
||||||
|
<button className="btn px-2 py-1" onClick={onClose} aria-label="Close agent panel">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{context && (
|
||||||
|
<div className="px-4 pt-3 text-xs muted">
|
||||||
|
Talking about <span style={{ color: 'var(--accent)' }}>{context.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||||
|
{!conversation && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="font-semibold">Your writing partner</h3>
|
||||||
|
<p className="mt-1 text-sm muted">
|
||||||
|
It can read and edit the brief, the outline, character dossiers and chapter prose —
|
||||||
|
the same data you see elsewhere in the app.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{starters.map((starter) => (
|
||||||
|
<button key={starter} className="btn justify-start text-left text-sm" 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 px-4 py-3"
|
||||||
|
style={{ borderTop: '1px solid var(--line)' }}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
submit(draft)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="input flex-1 resize-none"
|
||||||
|
rows={2}
|
||||||
|
value={draft}
|
||||||
|
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message }: { message: AgentMessage }) {
|
||||||
|
const isUser = message.role === 'User'
|
||||||
|
const match = isUser ? message.content.match(CONTEXT_PATTERN) : null
|
||||||
|
const contextLabel = match?.[1]
|
||||||
|
const content = match ? match[2] : message.content
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isUser ? 'flex justify-end' : ''}>
|
||||||
|
<div
|
||||||
|
className={`card max-w-[90%] px-3.5 py-2.5 ${isUser ? '' : 'w-full'}`}
|
||||||
|
style={isUser ? { background: 'var(--accent-soft)', borderColor: 'transparent' } : undefined}
|
||||||
|
>
|
||||||
|
{contextLabel && <div className="mb-1 text-[0.6875rem] muted">re: {contextLabel}</div>}
|
||||||
|
<div className="prose-serif whitespace-pre-wrap text-[0.9375rem]">{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
useCharacterBeats,
|
useCharacterBeats,
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { chapterLabel } from '../api/chapterLabel'
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
import type { ArcStage, Character, ChapterKind } from '../api/types'
|
import type { ArcStage, Character, ChapterKind } from '../api/types'
|
||||||
import { AutoField, ErrorNote } from './ui'
|
import { AutoField, ErrorNote } from './ui'
|
||||||
|
import { ConfirmModal } from './ConfirmModal'
|
||||||
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
export function CharacterArc({
|
export function CharacterArc({
|
||||||
novelId,
|
novelId,
|
||||||
@@ -32,6 +34,8 @@ export function CharacterArc({
|
|||||||
const reorder = useReorderArcStages(novelId)
|
const reorder = useReorderArcStages(novelId)
|
||||||
|
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
const [pendingStageId, setPendingStageId] = useState<string | null>(null)
|
||||||
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const stages = character.arcStages
|
const stages = character.arcStages
|
||||||
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
|
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
|
||||||
@@ -41,10 +45,15 @@ export function CharacterArc({
|
|||||||
if (!title.trim()) return
|
if (!title.trim()) return
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ characterId: character.id, title: title.trim() },
|
{ characterId: character.id, title: title.trim() },
|
||||||
{ onSuccess: () => setTitle('') },
|
{ onSuccess: (stage) => { setTitle(''); setPendingStageId(stage.id) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useHotkey('a', 'Add arc stage', () => titleInputRef.current?.focus(), {
|
||||||
|
group: 'Character',
|
||||||
|
enabled: canCreate,
|
||||||
|
})
|
||||||
|
|
||||||
const move = (index: number, delta: number) => {
|
const move = (index: number, delta: number) => {
|
||||||
const next = [...stages]
|
const next = [...stages]
|
||||||
const [moved] = next.splice(index, 1)
|
const [moved] = next.splice(index, 1)
|
||||||
@@ -80,6 +89,8 @@ export function CharacterArc({
|
|||||||
onMove={(delta) => move(index, delta)}
|
onMove={(delta) => move(index, delta)}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
|
autoFocusTitle={pendingStageId === stage.id}
|
||||||
|
onTitleAutoFocused={() => setPendingStageId(null)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -95,10 +106,14 @@ export function CharacterArc({
|
|||||||
{canCreate && (
|
{canCreate && (
|
||||||
<form onSubmit={submit} className="mt-3 flex gap-2">
|
<form onSubmit={submit} className="mt-3 flex gap-2">
|
||||||
<input
|
<input
|
||||||
|
ref={titleInputRef}
|
||||||
className="input flex-1"
|
className="input flex-1"
|
||||||
placeholder="Add a section — a short title, e.g. “spoiled noble”"
|
placeholder="Add a section — a short title, e.g. “spoiled noble”"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') setTitle('')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}>
|
<button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}>
|
||||||
Add
|
Add
|
||||||
@@ -125,6 +140,8 @@ function ArcStageRow({
|
|||||||
onMove,
|
onMove,
|
||||||
canWrite,
|
canWrite,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
autoFocusTitle,
|
||||||
|
onTitleAutoFocused,
|
||||||
}: {
|
}: {
|
||||||
novelId: string
|
novelId: string
|
||||||
stage: ArcStage
|
stage: ArcStage
|
||||||
@@ -135,10 +152,13 @@ function ArcStageRow({
|
|||||||
onMove: (delta: number) => void
|
onMove: (delta: number) => void
|
||||||
canWrite: boolean
|
canWrite: boolean
|
||||||
canDelete: boolean
|
canDelete: boolean
|
||||||
|
autoFocusTitle: boolean
|
||||||
|
onTitleAutoFocused: () => void
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateArcStage(novelId)
|
const update = useUpdateArcStage(novelId)
|
||||||
const remove = useDeleteArcStage(novelId)
|
const remove = useDeleteArcStage(novelId)
|
||||||
const setBeats = useSetArcStageBeats(novelId, stage.characterId)
|
const setBeats = useSetArcStageBeats(novelId, stage.characterId)
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
|
||||||
const addBeat = (beatId: string) => {
|
const addBeat = (beatId: string) => {
|
||||||
if (!beatId) return
|
if (!beatId) return
|
||||||
@@ -162,6 +182,9 @@ function ArcStageRow({
|
|||||||
value={stage.title}
|
value={stage.title}
|
||||||
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
|
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
|
||||||
readOnly={!canWrite}
|
readOnly={!canWrite}
|
||||||
|
autoFocus={autoFocusTitle}
|
||||||
|
selectOnFocus
|
||||||
|
onAutoFocused={onTitleAutoFocused}
|
||||||
/>
|
/>
|
||||||
<AutoField
|
<AutoField
|
||||||
value={stage.result}
|
value={stage.result}
|
||||||
@@ -266,9 +289,7 @@ function ArcStageRow({
|
|||||||
<button
|
<button
|
||||||
className="btn px-2 py-0.5 text-xs"
|
className="btn px-2 py-0.5 text-xs"
|
||||||
style={{ color: 'var(--accent)' }}
|
style={{ color: 'var(--accent)' }}
|
||||||
onClick={() => {
|
onClick={() => setConfirmingDelete(true)}
|
||||||
if (confirm(`Delete “${stage.title}” from the arc?`)) remove.mutate(stage.id)
|
|
||||||
}}
|
|
||||||
aria-label="Delete stage"
|
aria-label="Delete stage"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -277,6 +298,15 @@ function ArcStageRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confirmingDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete stage"
|
||||||
|
message={`Delete "${stage.title}" from the arc?`}
|
||||||
|
onConfirm={() => remove.mutate(stage.id)}
|
||||||
|
onClose={() => setConfirmingDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useImportJob, useInspectImport, useStartImport } from '../api/hooks'
|
import { useImportJob, useInspectImport, useStartImport, useUploadImportZip } from '../api/hooks'
|
||||||
import type { ImportInspection, ImportJob } from '../api/types'
|
import type { ImportInspection, ImportJob } from '../api/types'
|
||||||
import { ErrorNote, Modal, Spinner } from './ui'
|
import { ErrorNote, Modal, Spinner } from './ui'
|
||||||
|
import { ImportSourcePicker } from './ImportSourcePicker'
|
||||||
|
|
||||||
|
type SourceMode = 'browse' | 'upload' | 'path'
|
||||||
|
|
||||||
export function ImportDialog({
|
export function ImportDialog({
|
||||||
onClose,
|
onClose,
|
||||||
@@ -11,13 +14,16 @@ export function ImportDialog({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onImported?: (novelId: string) => void
|
onImported?: (novelId: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const [sourceMode, setSourceMode] = useState<SourceMode>('browse')
|
||||||
const [sourceRoot, setSourceRoot] = useState('')
|
const [sourceRoot, setSourceRoot] = useState('')
|
||||||
const [inspection, setInspection] = useState<ImportInspection | null>(null)
|
const [inspection, setInspection] = useState<ImportInspection | null>(null)
|
||||||
const [jobId, setJobId] = useState<string>()
|
const [jobId, setJobId] = useState<string>()
|
||||||
const [confirmingRestart, setConfirmingRestart] = useState(false)
|
const [confirmingRestart, setConfirmingRestart] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const inspect = useInspectImport()
|
const inspect = useInspectImport()
|
||||||
const start = useStartImport()
|
const start = useStartImport()
|
||||||
|
const upload = useUploadImportZip()
|
||||||
const job = useImportJob(jobId)
|
const job = useImportJob(jobId)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
@@ -33,6 +39,15 @@ export function ImportDialog({
|
|||||||
inspect.mutate(sourceRoot.trim(), { onSuccess: setInspection })
|
inspect.mutate(sourceRoot.trim(), { onSuccess: setInspection })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chooseSource = (root: string) => {
|
||||||
|
setSourceRoot(root)
|
||||||
|
inspect.mutate(root, { onSuccess: setInspection })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadZip = (file: File) => {
|
||||||
|
upload.mutate(file, { onSuccess: (result) => chooseSource(result.sourceRoot) })
|
||||||
|
}
|
||||||
|
|
||||||
const beginImport = (forceRestart = false) => {
|
const beginImport = (forceRestart = false) => {
|
||||||
start.mutate({ sourceRoot: sourceRoot.trim(), forceRestart }, { onSuccess: (created) => setJobId(created.id) })
|
start.mutate({ sourceRoot: sourceRoot.trim(), forceRestart }, { onSuccess: (created) => setJobId(created.id) })
|
||||||
}
|
}
|
||||||
@@ -55,11 +70,59 @@ export function ImportDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const busy = inspect.isPending || start.isPending || upload.isPending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Import outline" onClose={onClose}>
|
<Modal title="Import outline" onClose={onClose}>
|
||||||
<form onSubmit={check} className="grid gap-3">
|
<form onSubmit={check} className="grid gap-3">
|
||||||
|
{!inspection && (
|
||||||
|
<div id="import-source-mode" className="flex gap-1">
|
||||||
|
{(['browse', 'upload', 'path'] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={sourceMode === mode ? { color: 'var(--accent)' } : undefined}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setSourceMode(mode)}
|
||||||
|
>
|
||||||
|
{mode === 'browse' ? 'Browse' : mode === 'upload' ? 'Upload zip' : 'Enter path'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inspection && sourceMode === 'browse' && (
|
||||||
|
<ImportSourcePicker onSelect={chooseSource} disabled={busy} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inspection && sourceMode === 'upload' && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<input
|
||||||
|
id="import-zip-input"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
className="input"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) uploadZip(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-sm muted">
|
||||||
|
A zip of the folder holding outline.md, its chapter files and character
|
||||||
|
dossiers.
|
||||||
|
</p>
|
||||||
|
{upload.isPending && <Spinner label="Uploading" />}
|
||||||
|
{upload.error && <ErrorNote error={upload.error} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inspection && sourceMode === 'path' && (
|
||||||
|
<>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="label">Source folder</span>
|
<span className="label">Source folder or file</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -69,13 +132,17 @@ export function ImportDialog({
|
|||||||
setInspection(null)
|
setInspection(null)
|
||||||
}}
|
}}
|
||||||
placeholder="/home/you/Documents/Novels/my-outline"
|
placeholder="/home/you/Documents/Novels/my-outline"
|
||||||
disabled={inspect.isPending || start.isPending}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm muted">
|
<p className="text-sm muted">
|
||||||
Absolute path to the folder holding outline.md, its chapter files and character
|
Absolute path to the folder holding outline.md and its chapter/character
|
||||||
dossiers.
|
files, or to a single markdown file.
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inspection && <p className="text-sm muted">{sourceRoot}</p>}
|
||||||
|
|
||||||
{inspect.error && <ErrorNote error={inspect.error} />}
|
{inspect.error && <ErrorNote error={inspect.error} />}
|
||||||
{start.error && <ErrorNote error={start.error} />}
|
{start.error && <ErrorNote error={start.error} />}
|
||||||
@@ -96,7 +163,7 @@ export function ImportDialog({
|
|||||||
<button type="button" className="btn" onClick={onClose}>
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
{!inspection && (
|
{!inspection && sourceMode === 'path' && (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useImportBrowse } from '../api/hooks'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
import { Spinner } from './ui'
|
||||||
|
|
||||||
|
export function ImportSourcePicker({
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onSelect: (sourceRoot: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const [path, setPath] = useState('')
|
||||||
|
const browse = useImportBrowse(path)
|
||||||
|
|
||||||
|
if (browse.isError && browse.error instanceof ApiError && browse.error.status === 400) {
|
||||||
|
return (
|
||||||
|
<p id="import-picker-unavailable" className="text-sm muted">
|
||||||
|
No import folder is configured on this server.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browse.isLoading) {
|
||||||
|
return <Spinner label="Loading" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!browse.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = browse.data.relativePath ? browse.data.relativePath.split('/') : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="import-source-picker" className="grid gap-2">
|
||||||
|
<nav className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
|
<button type="button" className="btn" disabled={disabled} onClick={() => setPath('')}>
|
||||||
|
Import folder
|
||||||
|
</button>
|
||||||
|
{segments.map((segment, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<span className="muted">/</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setPath(segments.slice(0, i + 1).join('/'))}
|
||||||
|
>
|
||||||
|
{segment}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
id="import-source-picker-entries"
|
||||||
|
className="grid gap-1 rounded-md p-1"
|
||||||
|
style={{ background: 'var(--surface-sunken)', maxHeight: '16rem', overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
{browse.data.entries.length === 0 && <li className="px-2 py-1 text-sm muted">Empty folder.</li>}
|
||||||
|
{browse.data.entries.map((entry) => (
|
||||||
|
<li key={entry.relativePath} className="flex items-center justify-between gap-2 px-2 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 text-left text-sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => (entry.isDirectory ? setPath(entry.relativePath) : onSelect(entry.sourceRoot))}
|
||||||
|
>
|
||||||
|
{entry.isDirectory ? '📁' : '📄'} {entry.name}
|
||||||
|
{entry.isDirectory && entry.looksImportable && (
|
||||||
|
<span className="ml-2 text-xs" style={{ color: 'var(--accent)' }}>
|
||||||
|
outline found
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{entry.isDirectory && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={disabled || !entry.looksImportable}
|
||||||
|
onClick={() => onSelect(entry.sourceRoot)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, type KeyboardEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
useDeleteQuestion,
|
useDeleteQuestion,
|
||||||
useOpenQuestions,
|
useOpenQuestions,
|
||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from '../api/hooks'
|
} from '../api/hooks'
|
||||||
import type { OpenQuestion } from '../api/types'
|
import type { OpenQuestion } from '../api/types'
|
||||||
import { ErrorNote, Spinner } from './ui'
|
import { ErrorNote, Spinner } from './ui'
|
||||||
|
import { ConfirmModal } from './ConfirmModal'
|
||||||
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
|
const isSubmitCombo = (e: KeyboardEvent) => (e.metaKey || e.ctrlKey) && e.key === 'Enter'
|
||||||
|
|
||||||
export function OpenQuestions({
|
export function OpenQuestions({
|
||||||
novelId,
|
novelId,
|
||||||
@@ -34,8 +38,7 @@ export function OpenQuestions({
|
|||||||
const [question, setQuestion] = useState('')
|
const [question, setQuestion] = useState('')
|
||||||
const [detail, setDetail] = useState('')
|
const [detail, setDetail] = useState('')
|
||||||
|
|
||||||
const submit = (e: React.FormEvent) => {
|
const raiseQuestion = () => {
|
||||||
e.preventDefault()
|
|
||||||
if (!question.trim()) return
|
if (!question.trim()) return
|
||||||
raise.mutate(
|
raise.mutate(
|
||||||
{ question: question.trim(), detail: detail.trim() || undefined, ...scope },
|
{ question: question.trim(), detail: detail.trim() || undefined, ...scope },
|
||||||
@@ -49,6 +52,14 @@ export function OpenQuestions({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useHotkey('q', 'Raise a question', () => setAsking(true), { group: 'Questions', enabled: canCreate })
|
||||||
|
|
||||||
|
const cancelAsking = () => {
|
||||||
|
setAsking(false)
|
||||||
|
setQuestion('')
|
||||||
|
setDetail('')
|
||||||
|
}
|
||||||
|
|
||||||
const openCount = questions?.filter((q) => !q.isResolved).length ?? 0
|
const openCount = questions?.filter((q) => !q.isResolved).length ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +79,7 @@ export function OpenQuestions({
|
|||||||
Show resolved
|
Show resolved
|
||||||
</label>
|
</label>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className="btn" onClick={() => setAsking((open) => !open)}>
|
<button className="btn" onClick={() => (asking ? cancelAsking() : setAsking(true))}>
|
||||||
{asking ? 'Cancel' : 'Ask'}
|
{asking ? 'Cancel' : 'Ask'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -76,7 +87,23 @@ export function OpenQuestions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{asking && (
|
{asking && (
|
||||||
<form onSubmit={submit} className="mb-4 grid gap-2">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
raiseQuestion()
|
||||||
|
}}
|
||||||
|
className="mb-4 grid gap-2"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelAsking()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSubmitCombo(e)) {
|
||||||
|
e.preventDefault()
|
||||||
|
raiseQuestion()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -147,9 +174,14 @@ function QuestionRow({
|
|||||||
const [resolving, setResolving] = useState(false)
|
const [resolving, setResolving] = useState(false)
|
||||||
const [resolution, setResolution] = useState('')
|
const [resolution, setResolution] = useState('')
|
||||||
const [appendToNotes, setAppendToNotes] = useState(true)
|
const [appendToNotes, setAppendToNotes] = useState(true)
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
|
||||||
const submit = (e: React.FormEvent) => {
|
const cancelResolving = () => {
|
||||||
e.preventDefault()
|
setResolving(false)
|
||||||
|
setResolution('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitResolution = () => {
|
||||||
if (!resolution.trim()) return
|
if (!resolution.trim()) return
|
||||||
resolve.mutate(
|
resolve.mutate(
|
||||||
{ id: question.id, resolution: resolution.trim(), appendToNotes },
|
{ id: question.id, resolution: resolution.trim(), appendToNotes },
|
||||||
@@ -204,7 +236,7 @@ function QuestionRow({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn px-2 py-1 text-xs"
|
className="btn px-2 py-1 text-xs"
|
||||||
onClick={() => setResolving((open) => !open)}
|
onClick={() => (resolving ? cancelResolving() : setResolving(true))}
|
||||||
>
|
>
|
||||||
{resolving ? 'Cancel' : 'Resolve'}
|
{resolving ? 'Cancel' : 'Resolve'}
|
||||||
</button>
|
</button>
|
||||||
@@ -214,11 +246,7 @@ function QuestionRow({
|
|||||||
<button
|
<button
|
||||||
className="btn px-2 py-1 text-xs"
|
className="btn px-2 py-1 text-xs"
|
||||||
style={{ color: 'var(--accent)' }}
|
style={{ color: 'var(--accent)' }}
|
||||||
onClick={() => {
|
onClick={() => setConfirmingDelete(true)}
|
||||||
if (confirm('Delete this question? Resolving keeps the decision; deleting does not.')) {
|
|
||||||
remove.mutate(question.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -227,7 +255,23 @@ function QuestionRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{resolving && (
|
{resolving && (
|
||||||
<form onSubmit={submit} className="mt-2 grid gap-2">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
submitResolution()
|
||||||
|
}}
|
||||||
|
className="mt-2 grid gap-2"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelResolving()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSubmitCombo(e)) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitResolution()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
className="input"
|
className="input"
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -252,6 +296,15 @@ function QuestionRow({
|
|||||||
{resolve.error && <ErrorNote error={resolve.error} />}
|
{resolve.error && <ErrorNote error={resolve.error} />}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmingDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete question"
|
||||||
|
message="Delete this question? Resolving keeps the decision; deleting does not."
|
||||||
|
onConfirm={() => remove.mutate(question.id)}
|
||||||
|
onClose={() => setConfirmingDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function TagColorPicker({
|
|||||||
id="tag-color-custom-input"
|
id="tag-color-custom-input"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
type="color"
|
type="color"
|
||||||
value={isCustom ? value : '#9a4a2f'}
|
value={isCustom ? value : '#7c5cff'}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
function Icon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconDashboard(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<rect x="3.5" y="3.5" width="7" height="9" rx="1.5" />
|
||||||
|
<rect x="13.5" y="3.5" width="7" height="5" rx="1.5" />
|
||||||
|
<rect x="13.5" y="11.5" width="7" height="9" rx="1.5" />
|
||||||
|
<rect x="3.5" y="15.5" width="7" height="5" rx="1.5" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChapters(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M4 4.5c2-1 5-1 8 0 3-1 6-1 8 0v14c-2-1-5-1-8 0-3-1-6-1-8 0z" />
|
||||||
|
<path d="M12 4.5v14" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCharacters(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<circle cx="9" cy="8" r="3.25" />
|
||||||
|
<path d="M3.5 20c.7-3.4 2.8-5.5 5.5-5.5s4.8 2.1 5.5 5.5" />
|
||||||
|
<path d="M15.5 5.2c1.4.4 2.4 1.6 2.4 3s-1 2.6-2.4 3" />
|
||||||
|
<path d="M18 14.6c2 .5 3.4 2.2 4 4.9" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTags(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M11.5 3.5h5.8a1 1 0 0 1 .7.3l3 3a1 1 0 0 1 .3.7v5.8a1 1 0 0 1-.3.7l-8.3 8.3a1 1 0 0 1-1.4 0l-8-8a1 1 0 0 1 0-1.4l8.3-8.3a1 1 0 0 1 .7-.3z" />
|
||||||
|
<circle cx="16.5" cy="7.5" r="1.25" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconLocations(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
|
||||||
|
<circle cx="12" cy="9.5" r="2.5" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconAgent(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M12 3.5l1.4 3.4 3.4 1.4-3.4 1.4L12 13.1l-1.4-3.4-3.4-1.4 3.4-1.4z" />
|
||||||
|
<path d="M18.5 14.5l.8 1.9 1.9.8-1.9.8-.8 1.9-.8-1.9-1.9-.8 1.9-.8z" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTrash(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M4.5 7h15" />
|
||||||
|
<path d="M9.5 7V4.8a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V7" />
|
||||||
|
<path d="M6.5 7l1 12.2a1 1 0 0 0 1 .8h7a1 1 0 0 0 1-.8L17.5 7" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSettings(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M17.7 6.3l-1.6 1.6M7.9 16.1l-1.6 1.6M17.7 17.7l-1.6-1.6M7.9 7.9 6.3 6.3" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChevronDown(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPlus(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconArrowRight(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M4.5 12h15M13.5 6l6 6-6 6" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
import { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||||
|
import { draftStatusColor } from '../api/stage'
|
||||||
import type { DraftStatus } from '../api/types'
|
import type { DraftStatus } from '../api/types'
|
||||||
|
import { focusNextTabbable } from '../keyboard/focus'
|
||||||
|
|
||||||
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -35,16 +37,8 @@ export function EmptyState({ title, hint }: { title: string; hint?: ReactNode })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTone: Record<DraftStatus, string> = {
|
|
||||||
Planned: '#8a8178',
|
|
||||||
Outlined: '#5b7fa8',
|
|
||||||
Drafted: '#a8813f',
|
|
||||||
Revised: '#63914f',
|
|
||||||
Final: '#4a8f7b',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: DraftStatus }) {
|
export function StatusBadge({ status }: { status: DraftStatus }) {
|
||||||
const tone = statusTone[status]
|
const tone = draftStatusColor(status)
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-block rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold tracking-wide uppercase"
|
className="inline-block rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold tracking-wide uppercase"
|
||||||
@@ -66,6 +60,9 @@ export function AutoField({
|
|||||||
suggestions,
|
suggestions,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
autoFocus,
|
||||||
|
selectOnFocus,
|
||||||
|
onAutoFocused,
|
||||||
}: {
|
}: {
|
||||||
label?: string
|
label?: string
|
||||||
value: string | null | undefined
|
value: string | null | undefined
|
||||||
@@ -77,10 +74,17 @@ export function AutoField({
|
|||||||
suggestions?: readonly string[]
|
suggestions?: readonly string[]
|
||||||
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
|
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
autoFocus?: boolean
|
||||||
|
selectOnFocus?: boolean
|
||||||
|
onAutoFocused?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState(value ?? '')
|
const [draft, setDraft] = useState(value ?? '')
|
||||||
const committed = useRef(value ?? '')
|
const committed = useRef(value ?? '')
|
||||||
const suggestionsId = useId()
|
const suggestionsId = useId()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const onAutoFocusedRef = useRef(onAutoFocused)
|
||||||
|
onAutoFocusedRef.current = onAutoFocused
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const incoming = value ?? ''
|
const incoming = value ?? ''
|
||||||
@@ -90,6 +94,14 @@ export function AutoField({
|
|||||||
}
|
}
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoFocus) return
|
||||||
|
const field = multiline ? textareaRef.current : inputRef.current
|
||||||
|
field?.focus()
|
||||||
|
if (selectOnFocus) field?.select()
|
||||||
|
onAutoFocusedRef.current?.()
|
||||||
|
}, [autoFocus, multiline, selectOnFocus])
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (draft !== committed.current) {
|
if (draft !== committed.current) {
|
||||||
committed.current = draft
|
committed.current = draft
|
||||||
@@ -97,6 +109,31 @@ export function AutoField({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revert = () => setDraft(committed.current)
|
||||||
|
|
||||||
|
const onSingleLineKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
revert()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
commit()
|
||||||
|
if (e.metaKey || e.ctrlKey) return
|
||||||
|
focusNextTabbable(e.currentTarget, e.shiftKey ? -1 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMultilineKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
revert()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const className = `input ${serif ? 'prose-serif' : ''}`
|
const className = `input ${serif ? 'prose-serif' : ''}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,25 +141,28 @@ export function AutoField({
|
|||||||
{label && <span className="label">{label}</span>}
|
{label && <span className="label">{label}</span>}
|
||||||
{multiline ? (
|
{multiline ? (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
className={className}
|
className={className}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
|
onKeyDown={onMultilineKeyDown}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
className={className}
|
className={className}
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
list={suggestions?.length ? suggestionsId : undefined}
|
list={suggestions?.length ? suggestionsId : undefined}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
onKeyDown={onSingleLineKeyDown}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{suggestions?.length ? (
|
{suggestions?.length ? (
|
||||||
@@ -144,17 +184,25 @@ export function Select<T extends string>({
|
|||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
|
autoFocus,
|
||||||
}: {
|
}: {
|
||||||
id?: string
|
id?: string
|
||||||
label?: string
|
label?: string
|
||||||
value: T
|
value: T
|
||||||
options: readonly T[]
|
options: readonly T[]
|
||||||
onChange: (next: T) => void
|
onChange: (next: T) => void
|
||||||
|
autoFocus?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const selectRef = useRef<HTMLSelectElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) selectRef.current?.focus()
|
||||||
|
}, [autoFocus])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
{label && <span className="label">{label}</span>}
|
{label && <span className="label">{label}</span>}
|
||||||
<select id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
|
<select ref={selectRef} id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
|
|||||||
@@ -1,58 +1,56 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
|
--font-display: 'Fraunces Variable', Georgia, serif;
|
||||||
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
--font-ui: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
--font-serif: 'Source Serif 4 Variable', Georgia, serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--paper: #faf7f0;
|
--canvas: #0c0a12;
|
||||||
--surface: #ffffff;
|
--surface: #1b1825;
|
||||||
--surface-sunken: #f2ede2;
|
--surface-sunken: #100e17;
|
||||||
--ink: #241f1a;
|
--ink: #f1eef8;
|
||||||
--ink-muted: #6b6157;
|
--ink-muted: #8b859a;
|
||||||
--line: #e0d8c8;
|
--line: #2c2838;
|
||||||
--accent: #9a4a2f;
|
|
||||||
--accent-soft: #f6e9e2;
|
--accent: #7c5cff;
|
||||||
color-scheme: light;
|
--accent-soft: #241d3d;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
|
||||||
|
--stage-1: #7c5cff;
|
||||||
|
--stage-2: #2f8fe0;
|
||||||
|
--stage-3: #ff7a45;
|
||||||
|
--stage-4: #14b88a;
|
||||||
|
--stage-5: #d9a504;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root:not([data-theme='dark']) {
|
||||||
--paper: #16151a;
|
--canvas: #f7f6fb;
|
||||||
--surface: #1e1d24;
|
--surface: #ffffff;
|
||||||
--surface-sunken: #131217;
|
--surface-sunken: #eeecf6;
|
||||||
--ink: #ece7de;
|
--ink: #14121a;
|
||||||
--ink-muted: #9a9288;
|
--ink-muted: #6b6577;
|
||||||
--line: #322f39;
|
--line: #e2dfec;
|
||||||
--accent: #e08b62;
|
--accent-soft: #efe9ff;
|
||||||
--accent-soft: #2c2229;
|
--accent-ink: #ffffff;
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
: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'] {
|
:root[data-theme='light'] {
|
||||||
--paper: #faf7f0;
|
--canvas: #f7f6fb;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-sunken: #f2ede2;
|
--surface-sunken: #eeecf6;
|
||||||
--ink: #241f1a;
|
--ink: #14121a;
|
||||||
--ink-muted: #6b6157;
|
--ink-muted: #6b6577;
|
||||||
--line: #e0d8c8;
|
--line: #e2dfec;
|
||||||
--accent: #9a4a2f;
|
--accent-soft: #efe9ff;
|
||||||
--accent-soft: #f6e9e2;
|
--accent-ink: #ffffff;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,21 +61,44 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--paper);
|
background: var(--canvas);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--canvas), 0 0 0 4px var(--accent, #7c5cff);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition;
|
@apply inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -94,11 +115,11 @@ body {
|
|||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
filter: brightness(1.08);
|
filter: brightness(1.1);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +133,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
|
@apply w-full rounded-lg px-2.5 py-1.5 text-sm outline-none transition;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -121,7 +142,7 @@ body {
|
|||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@@ -134,17 +155,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose-serif {
|
.prose-serif {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-serif);
|
||||||
@apply text-[1.0625rem] leading-relaxed;
|
@apply text-[1.0625rem] leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-preview {
|
.markdown-preview {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-serif);
|
||||||
@apply text-[1.0625rem] leading-relaxed;
|
@apply text-[1.0625rem] leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-preview :is(h1, h2, h3, h4) {
|
.markdown-preview :is(h1, h2, h3, h4) {
|
||||||
@apply mt-5 mb-2 font-semibold first:mt-0;
|
@apply mt-5 mb-2 font-semibold first:mt-0;
|
||||||
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-preview h1 {
|
.markdown-preview h1 {
|
||||||
@@ -183,6 +205,7 @@ body {
|
|||||||
|
|
||||||
.markdown-preview code {
|
.markdown-preview code {
|
||||||
@apply rounded px-1 py-0.5 text-sm;
|
@apply rounded px-1 py-0.5 text-sm;
|
||||||
|
font-family: var(--font-mono);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useHelpOverlay } from './HelpOverlayContext'
|
import { useHelpOverlay } from './HelpOverlayContext'
|
||||||
import { useHotkeysList } from './HotkeysContext'
|
import { useHotkeysList } from './HotkeysContext'
|
||||||
|
|
||||||
|
const CONVENTIONS = [
|
||||||
|
'Escape cancels or closes. It reverts the field you’re in; fields you already tabbed past stay saved.',
|
||||||
|
'Enter commits a single-line field and moves to the next one, like Tab.',
|
||||||
|
'mod+Enter commits a multiline field, or finishes the record you’re editing.',
|
||||||
|
'Destructive actions always confirm through a dialog you can dismiss with Escape — never a browser popup.',
|
||||||
|
]
|
||||||
|
|
||||||
const formatToken = (token: string) => {
|
const formatToken = (token: string) => {
|
||||||
if (token === 'mod') return '⌘/Ctrl'
|
if (token === 'mod') return '⌘/Ctrl'
|
||||||
if (token.length === 1) return token.toUpperCase()
|
if (token.length === 1) return token.toUpperCase()
|
||||||
@@ -80,13 +87,25 @@ export function HelpOverlay() {
|
|||||||
<ul className="grid gap-2">
|
<ul className="grid gap-2">
|
||||||
{groupShortcuts.map((shortcut) => (
|
{groupShortcuts.map((shortcut) => (
|
||||||
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
|
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
|
||||||
<span>{shortcut.description}</span>
|
<span>
|
||||||
|
{shortcut.description}
|
||||||
|
{shortcut.allowInInputs && <span className="muted"> · works while typing</span>}
|
||||||
|
</span>
|
||||||
<KeySequence keys={shortcut.keys} />
|
<KeySequence keys={shortcut.keys} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="label mb-2">Conventions</h3>
|
||||||
|
<ul className="grid gap-2 text-sm muted">
|
||||||
|
{CONVENTIONS.map((convention) => (
|
||||||
|
<li key={convention}>{convention}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const TABBABLE_SELECTOR = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
|
export function focusNextTabbable(from: HTMLElement, direction: 1 | -1, within?: HTMLElement | null) {
|
||||||
|
const scope = within ?? document.body
|
||||||
|
const candidates = [...scope.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)]
|
||||||
|
const index = candidates.indexOf(from)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const target = candidates[index + direction]
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
target.focus()
|
||||||
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||||
|
target.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWithin(container: HTMLElement | null, node: Node | null): boolean {
|
||||||
|
if (!container || !node) return false
|
||||||
|
return container.contains(node)
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import '@fontsource-variable/fraunces/wght.css'
|
||||||
|
import '@fontsource-variable/fraunces/wght-italic.css'
|
||||||
|
import '@fontsource-variable/inter/wght.css'
|
||||||
|
import '@fontsource-variable/source-serif-4/wght.css'
|
||||||
|
import '@fontsource/jetbrains-mono/400.css'
|
||||||
|
import '@fontsource/jetbrains-mono/600.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
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'
|
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
|
||||||
|
|
||||||
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 { novelId = '' } = useParams()
|
|
||||||
const { data: conversations } = useConversations(novelId)
|
|
||||||
const [conversationId, setConversationId] = useState<string | undefined>()
|
|
||||||
const { data: conversation } = useConversation(conversationId)
|
|
||||||
const send = useSendAgentMessage(novelId)
|
|
||||||
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) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
useHotkey('n', 'New conversation', () => setConversationId(undefined), { group: 'Agent' })
|
|
||||||
useHotkey('mod+Enter', 'Send message', () => submit(draft), {
|
|
||||||
group: 'Agent',
|
|
||||||
allowInInputs: true,
|
|
||||||
enabled: draft.trim().length > 0 && !send.isPending,
|
|
||||||
})
|
|
||||||
|
|
||||||
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 and chapter
|
|
||||||
prose — 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)}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, type MouseEvent } from 'react'
|
import { useEffect, useRef, useState, type MouseEvent } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
useAssignCharacterToBeats,
|
useAssignCharacterToBeats,
|
||||||
useChapter,
|
useChapter,
|
||||||
@@ -29,12 +29,14 @@ import { useCharacterContextMenu } from '../components/CharacterContextMenu'
|
|||||||
import { MarkdownEditor } from '../components/MarkdownEditor'
|
import { MarkdownEditor } from '../components/MarkdownEditor'
|
||||||
import { OpenQuestions } from '../components/OpenQuestions'
|
import { OpenQuestions } from '../components/OpenQuestions'
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
import { isWithin } from '../keyboard/focus'
|
||||||
|
|
||||||
type ChapterTab = 'outline' | 'prose'
|
type ChapterTab = 'outline' | 'prose'
|
||||||
|
|
||||||
export default function ChapterPage() {
|
export default function ChapterPage() {
|
||||||
const { novelId = '', chapterId = '' } = useParams()
|
const { novelId = '', chapterId = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const { data: chapter, isPending, error } = useChapter(chapterId)
|
const { data: chapter, isPending, error } = useChapter(chapterId)
|
||||||
const { data: novel } = useNovel(novelId)
|
const { data: novel } = useNovel(novelId)
|
||||||
const { data: characters } = useCharacters(novelId)
|
const { data: characters } = useCharacters(novelId)
|
||||||
@@ -47,13 +49,32 @@ export default function ChapterPage() {
|
|||||||
const createBeat = useCreateBeat(chapterId, novelId)
|
const createBeat = useCreateBeat(chapterId, novelId)
|
||||||
const [tab, setTab] = useState<ChapterTab>('outline')
|
const [tab, setTab] = useState<ChapterTab>('outline')
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
const [editingBeatId, setEditingBeatId] = useState<string | null>(null)
|
||||||
|
const [focusBeatId, setFocusBeatId] = useState<string | null>(null)
|
||||||
const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId)
|
const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId)
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
const canWrite = can('Write', novel)
|
const canWrite = can('Write', novel)
|
||||||
const canCreate = can('CreateContent', novel)
|
const canCreate = can('CreateContent', novel)
|
||||||
const canDelete = can('DeleteContent', novel)
|
const canDelete = can('DeleteContent', novel)
|
||||||
|
|
||||||
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
const focusTitleOnArrival = Boolean((location.state as { focusTitle?: boolean } | null)?.focusTitle)
|
||||||
|
|
||||||
|
const clearFocusTitleState = () => navigate(location.pathname, { replace: true, state: null })
|
||||||
|
|
||||||
|
const addBeat = () => {
|
||||||
|
if (!canCreate) return
|
||||||
|
createBeat.mutate(
|
||||||
|
{ title: 'New beat' },
|
||||||
|
{
|
||||||
|
onSuccess: (beat) => {
|
||||||
|
setEditingBeatId(beat.id)
|
||||||
|
setFocusBeatId(beat.id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useHotkey('b', 'Add beat', addBeat, { group: 'Chapter' })
|
||||||
|
|
||||||
const currentIndex = chapters?.findIndex((c) => c.id === chapterId) ?? -1
|
const currentIndex = chapters?.findIndex((c) => c.id === chapterId) ?? -1
|
||||||
const prevChapter = currentIndex > 0 ? chapters?.[currentIndex - 1] : undefined
|
const prevChapter = currentIndex > 0 ? chapters?.[currentIndex - 1] : undefined
|
||||||
@@ -168,6 +189,9 @@ export default function ChapterPage() {
|
|||||||
value={chapter.title}
|
value={chapter.title}
|
||||||
onCommit={(title) => title.trim() && patch({ title })}
|
onCommit={(title) => title.trim() && patch({ title })}
|
||||||
readOnly={!canWrite}
|
readOnly={!canWrite}
|
||||||
|
autoFocus={focusTitleOnArrival}
|
||||||
|
selectOnFocus
|
||||||
|
onAutoFocused={clearFocusTitleState}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
id="chapter-kind-select"
|
id="chapter-kind-select"
|
||||||
@@ -197,22 +221,13 @@ export default function ChapterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<TagEditor
|
|
||||||
label="Tags"
|
|
||||||
tags={chapter.tags}
|
|
||||||
suggestions={suggestions}
|
|
||||||
onChange={(tags) => canWrite && patch({ tags })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-end justify-between gap-4">
|
<div className="mt-4 flex items-end justify-between gap-4">
|
||||||
<div className="text-sm muted">
|
<div className="text-sm muted">
|
||||||
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
|
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
|
||||||
</div>
|
</div>
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||||
Delete chapter
|
Move to trash
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,8 +250,9 @@ export default function ChapterPage() {
|
|||||||
|
|
||||||
{confirmingDelete && (
|
{confirmingDelete && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title="Delete chapter"
|
title="Move to trash"
|
||||||
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
|
message={`Move chapter "${chapter.title}" and its beats to the trash? You can restore it from the Trash page.`}
|
||||||
|
confirmLabel="Move to trash"
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
remove.mutate(chapter.id, {
|
remove.mutate(chapter.id, {
|
||||||
onSuccess: () => navigate(`/novels/${novelId}/chapters`),
|
onSuccess: () => navigate(`/novels/${novelId}/chapters`),
|
||||||
@@ -257,7 +273,6 @@ export default function ChapterPage() {
|
|||||||
value={chapter.summary}
|
value={chapter.summary}
|
||||||
multiline
|
multiline
|
||||||
rows={5}
|
rows={5}
|
||||||
serif
|
|
||||||
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
|
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
|
||||||
onCommit={(summary) => patch({ summary })}
|
onCommit={(summary) => patch({ summary })}
|
||||||
onContextMenu={(e) => handleContextMenu(e, () => {})}
|
onContextMenu={(e) => handleContextMenu(e, () => {})}
|
||||||
@@ -275,14 +290,14 @@ export default function ChapterPage() {
|
|||||||
onCharacterContextMenu={handleContextMenu}
|
onCharacterContextMenu={handleContextMenu}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
|
editingId={editingBeatId}
|
||||||
|
setEditingId={setEditingBeatId}
|
||||||
|
focusBeatId={focusBeatId}
|
||||||
|
setFocusBeatId={setFocusBeatId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button
|
<button className="btn btn-primary mt-3" onClick={addBeat} disabled={createBeat.isPending}>
|
||||||
className="btn btn-primary mt-3"
|
|
||||||
onClick={() => createBeat.mutate({ title: 'New beat' })}
|
|
||||||
disabled={createBeat.isPending}
|
|
||||||
>
|
|
||||||
Add beat
|
Add beat
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -329,6 +344,15 @@ export default function ChapterPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section id="chapter-tags" className="card mt-6 p-5">
|
||||||
|
<TagEditor
|
||||||
|
label="Tags"
|
||||||
|
tags={chapter.tags}
|
||||||
|
suggestions={suggestions}
|
||||||
|
onChange={(tags) => canWrite && patch({ tags })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
{menuElement}
|
{menuElement}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -346,6 +370,10 @@ function BeatTable({
|
|||||||
onCharacterContextMenu,
|
onCharacterContextMenu,
|
||||||
canWrite,
|
canWrite,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
editingId,
|
||||||
|
setEditingId,
|
||||||
|
focusBeatId,
|
||||||
|
setFocusBeatId,
|
||||||
}: {
|
}: {
|
||||||
chapter: Chapter
|
chapter: Chapter
|
||||||
novelId: string
|
novelId: string
|
||||||
@@ -359,13 +387,16 @@ function BeatTable({
|
|||||||
) => void
|
) => void
|
||||||
canWrite: boolean
|
canWrite: boolean
|
||||||
canDelete: boolean
|
canDelete: boolean
|
||||||
|
editingId: string | null
|
||||||
|
setEditingId: (id: string | null) => void
|
||||||
|
focusBeatId: string | null
|
||||||
|
setFocusBeatId: (id: string | null) => void
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateBeat(chapter.id, novelId)
|
const update = useUpdateBeat(chapter.id, novelId)
|
||||||
const remove = useDeleteBeat(chapter.id, novelId)
|
const remove = useDeleteBeat(chapter.id, novelId)
|
||||||
const reorder = useReorderBeats(chapter.id)
|
const reorder = useReorderBeats(chapter.id)
|
||||||
const assignCharacter = useAssignCharacterToBeats(chapter.id)
|
const assignCharacter = useAssignCharacterToBeats(chapter.id)
|
||||||
const moveBeats = useMoveBeats(chapter.id)
|
const moveBeats = useMoveBeats(chapter.id)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
|
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
const [assignCharacterId, setAssignCharacterId] = useState('')
|
const [assignCharacterId, setAssignCharacterId] = useState('')
|
||||||
@@ -373,6 +404,25 @@ function BeatTable({
|
|||||||
const [focusedBeatId, setFocusedBeatId] = useState<string | null>(null)
|
const [focusedBeatId, setFocusedBeatId] = useState<string | null>(null)
|
||||||
const [dragBeatId, setDragBeatId] = useState<string | null>(null)
|
const [dragBeatId, setDragBeatId] = useState<string | null>(null)
|
||||||
const [dragOverBeatId, setDragOverBeatId] = useState<string | null>(null)
|
const [dragOverBeatId, setDragOverBeatId] = useState<string | null>(null)
|
||||||
|
const [returnFocusBeatId, setReturnFocusBeatId] = useState<string | null>(null)
|
||||||
|
const rowRefs = useRef(new Map<string, HTMLTableRowElement>())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!returnFocusBeatId) return
|
||||||
|
document.getElementById(`beat-${returnFocusBeatId}`)?.focus()
|
||||||
|
setReturnFocusBeatId(null)
|
||||||
|
}, [returnFocusBeatId])
|
||||||
|
|
||||||
|
const openEdit = (beatId: string) => {
|
||||||
|
if (!canWrite) return
|
||||||
|
setEditingId(beatId)
|
||||||
|
setFocusBeatId(beatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEdit = (beatId: string) => {
|
||||||
|
setEditingId(null)
|
||||||
|
setReturnFocusBeatId(beatId)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleSelected = (id: string) =>
|
const toggleSelected = (id: string) =>
|
||||||
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
|
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
|
||||||
@@ -450,7 +500,7 @@ function BeatTable({
|
|||||||
const moveButtonClass =
|
const moveButtonClass =
|
||||||
'flex h-6 w-6 items-center justify-center rounded text-base leading-none transition hover:bg-[var(--accent-soft)] disabled:opacity-25 disabled:hover:bg-transparent'
|
'flex h-6 w-6 items-center justify-center rounded text-base leading-none transition hover:bg-[var(--accent-soft)] disabled:opacity-25 disabled:hover:bg-transparent'
|
||||||
|
|
||||||
const renderMoveButtons = (beat: Beat, index: number) => (
|
const renderMoveButtons = (beat: Beat, index: number, focusable: boolean) => (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className={canWrite ? 'cursor-grab text-base muted' : 'text-base muted'}
|
className={canWrite ? 'cursor-grab text-base muted' : 'text-base muted'}
|
||||||
@@ -464,6 +514,7 @@ function BeatTable({
|
|||||||
<button
|
<button
|
||||||
id={`move-beat-up-${beat.id}`}
|
id={`move-beat-up-${beat.id}`}
|
||||||
className={moveButtonClass}
|
className={moveButtonClass}
|
||||||
|
tabIndex={focusable ? undefined : -1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
move(index, -1)
|
move(index, -1)
|
||||||
@@ -477,6 +528,7 @@ function BeatTable({
|
|||||||
<button
|
<button
|
||||||
id={`move-beat-down-${beat.id}`}
|
id={`move-beat-down-${beat.id}`}
|
||||||
className={moveButtonClass}
|
className={moveButtonClass}
|
||||||
|
tabIndex={focusable ? undefined : -1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
move(index, 1)
|
move(index, 1)
|
||||||
@@ -576,28 +628,48 @@ function BeatTable({
|
|||||||
<tr
|
<tr
|
||||||
key={beat.id}
|
key={beat.id}
|
||||||
id={`beat-${beat.id}`}
|
id={`beat-${beat.id}`}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) rowRefs.current.set(beat.id, el)
|
||||||
|
else rowRefs.current.delete(beat.id)
|
||||||
|
}}
|
||||||
style={{ borderBottom: '1px solid var(--line)', background: 'var(--accent-soft)' }}
|
style={{ borderBottom: '1px solid var(--line)', background: 'var(--accent-soft)' }}
|
||||||
onBlur={(e) => {
|
onBlur={() => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null)
|
window.setTimeout(() => {
|
||||||
|
if (!isWithin(rowRefs.current.get(beat.id) ?? null, document.activeElement)) {
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeEdit(beat.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
|
closeEdit(beat.id)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)}
|
|
||||||
>
|
>
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
tabIndex={-1}
|
||||||
aria-label={`Select beat ${beat.title}`}
|
aria-label={`Select beat ${beat.title}`}
|
||||||
checked={selectedIds.includes(beat.id)}
|
checked={selectedIds.includes(beat.id)}
|
||||||
onChange={() => toggleSelected(beat.id)}
|
onChange={() => toggleSelected(beat.id)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
|
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index, false)}</td>
|
||||||
|
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">
|
||||||
<AutoField
|
<AutoField
|
||||||
value={beat.title}
|
value={beat.title}
|
||||||
placeholder="Three to five words"
|
placeholder="Three to five words"
|
||||||
onCommit={(title) => title.trim() && patch(beat.id, { title })}
|
onCommit={(title) => title.trim() && patch(beat.id, { title })}
|
||||||
|
autoFocus={focusBeatId === beat.id}
|
||||||
|
selectOnFocus
|
||||||
|
onAutoFocused={() => setFocusBeatId(null)}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<TagEditor
|
<TagEditor
|
||||||
@@ -622,7 +694,6 @@ function BeatTable({
|
|||||||
value={beat.whatHappened}
|
value={beat.whatHappened}
|
||||||
multiline
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
serif
|
|
||||||
placeholder="The event itself."
|
placeholder="The event itself."
|
||||||
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
|
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
|
||||||
onContextMenu={(e) =>
|
onContextMenu={(e) =>
|
||||||
@@ -638,7 +709,6 @@ function BeatTable({
|
|||||||
value={beat.whatsNext}
|
value={beat.whatsNext}
|
||||||
multiline
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
serif
|
|
||||||
placeholder="What it sets in motion."
|
placeholder="What it sets in motion."
|
||||||
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
|
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
|
||||||
onContextMenu={(e) =>
|
onContextMenu={(e) =>
|
||||||
@@ -654,8 +724,9 @@ function BeatTable({
|
|||||||
<button
|
<button
|
||||||
className="text-xs leading-none"
|
className="text-xs leading-none"
|
||||||
style={{ color: 'var(--accent)' }}
|
style={{ color: 'var(--accent)' }}
|
||||||
onClick={() => setEditingId(null)}
|
onClick={() => closeEdit(beat.id)}
|
||||||
aria-label="Done editing beat"
|
aria-label="Done editing beat"
|
||||||
|
title="Done (mod+Enter)"
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
</button>
|
</button>
|
||||||
@@ -690,14 +761,14 @@ function BeatTable({
|
|||||||
opacity: dragBeatId === beat.id ? 0.4 : 1,
|
opacity: dragBeatId === beat.id ? 0.4 : 1,
|
||||||
boxShadow: dragOverBeatId === beat.id && dragBeatId !== beat.id ? 'inset 0 2px 0 0 var(--accent)' : undefined,
|
boxShadow: dragOverBeatId === beat.id && dragBeatId !== beat.id ? 'inset 0 2px 0 0 var(--accent)' : undefined,
|
||||||
}}
|
}}
|
||||||
onClick={canWrite ? () => setEditingId(beat.id) : undefined}
|
onClick={canWrite ? () => openEdit(beat.id) : undefined}
|
||||||
onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined}
|
onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
canWrite
|
canWrite
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setEditingId(beat.id)
|
openEdit(beat.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -740,7 +811,7 @@ function BeatTable({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
|
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index, true)}</td>
|
||||||
|
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">
|
||||||
<div className="font-medium">{beat.title}</div>
|
<div className="font-medium">{beat.title}</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useChapters, useCreateChapter, useNovel } from '../api/hooks'
|
import { useChapters, useCreateChapter, useNovel } from '../api/hooks'
|
||||||
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||||
import { TagChip } from '../components/TagEditor'
|
import { TagChip } from '../components/TagEditor'
|
||||||
@@ -7,13 +7,25 @@ import { useHotkey } from '../keyboard/HotkeysContext'
|
|||||||
|
|
||||||
export default function ChaptersPage() {
|
export default function ChaptersPage() {
|
||||||
const { novelId = '' } = useParams()
|
const { novelId = '' } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { data: chapters, isPending, error } = useChapters(novelId)
|
const { data: chapters, isPending, error } = useChapters(novelId)
|
||||||
const { data: novel } = useNovel(novelId)
|
const { data: novel } = useNovel(novelId)
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
const canCreate = can('CreateContent', novel)
|
const canCreate = can('CreateContent', novel)
|
||||||
const create = useCreateChapter(novelId)
|
const create = useCreateChapter(novelId)
|
||||||
|
|
||||||
useHotkey('n', 'Add chapter', () => canCreate && create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' })
|
const addChapter = () => {
|
||||||
|
if (!canCreate) return
|
||||||
|
create.mutate(
|
||||||
|
{ title: 'Untitled chapter' },
|
||||||
|
{
|
||||||
|
onSuccess: (chapter) =>
|
||||||
|
navigate(`/novels/${novelId}/chapters/${chapter.id}`, { state: { focusTitle: true } }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useHotkey('n', 'Add chapter', addChapter, { group: 'Chapters' })
|
||||||
|
|
||||||
if (isPending) return <Spinner label="Loading chapters" />
|
if (isPending) return <Spinner label="Loading chapters" />
|
||||||
if (error) return <ErrorNote error={error} />
|
if (error) return <ErrorNote error={error} />
|
||||||
@@ -23,11 +35,7 @@ export default function ChaptersPage() {
|
|||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
<h2 className="text-xl font-semibold">Chapters</h2>
|
<h2 className="text-xl font-semibold">Chapters</h2>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button
|
<button className="btn btn-primary" onClick={addChapter} disabled={create.isPending}>
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => create.mutate({ title: 'Untitled chapter' })}
|
|
||||||
disabled={create.isPending}
|
|
||||||
>
|
|
||||||
Add chapter
|
Add chapter
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -292,8 +292,9 @@ function CharacterSheet({
|
|||||||
|
|
||||||
{confirmingDelete && (
|
{confirmingDelete && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title="Delete character"
|
title="Move to trash"
|
||||||
message={`Delete ${character.name}? This cannot be undone.`}
|
message={`Move ${character.name} to the trash? You can restore it from the Trash page.`}
|
||||||
|
confirmLabel="Move to trash"
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) })
|
remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ function CharacterTable({ characters, novelId }: { characters: Character[]; nove
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddCharacterModal({
|
export function AddCharacterModal({
|
||||||
novelId,
|
novelId,
|
||||||
onClose,
|
onClose,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { useState } from 'react'
|
||||||
import { useChapters, useCharacters, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import type { Novel, TagSummary } from '../api/types'
|
import { useChapters, useCharacters, useCreateChapter, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
|
||||||
|
import type { ChapterSummary, Novel, TagSummary } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||||
import { ContributionGraph } from '../components/ContributionGraph'
|
import { ContributionGraph } from '../components/ContributionGraph'
|
||||||
|
import { IconArrowRight, IconCharacters, IconChapters as IconChapter, IconPlus } from '../components/icons'
|
||||||
|
import { AddCharacterModal } from './CharactersPage'
|
||||||
|
|
||||||
const RECENT_COUNT = 5
|
const RECENT_COUNT = 5
|
||||||
const RECENT_CHAPTERS_COUNT = 10
|
const RECENT_CHAPTERS_COUNT = 10
|
||||||
@@ -18,15 +21,37 @@ export default function DashboardPage() {
|
|||||||
return novel.phase === 'Brainstorming' ? (
|
return novel.phase === 'Brainstorming' ? (
|
||||||
<BrainstormingDashboard novel={novel} />
|
<BrainstormingDashboard novel={novel} />
|
||||||
) : (
|
) : (
|
||||||
<OutliningDashboard novelId={novelId} />
|
<WorkDashboard novelId={novelId} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrainstormingDashboard({ novel }: { novel: Novel }) {
|
function BrainstormingDashboard({ novel }: { novel: Novel }) {
|
||||||
const update = useUpdateNovel(novel.id)
|
const update = useUpdateNovel(novel.id)
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
|
const canCreate = can('CreateContent', novel)
|
||||||
|
const [addingCharacter, setAddingCharacter] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{canCreate && (
|
||||||
|
<section className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ActionCard
|
||||||
|
icon={IconPlus}
|
||||||
|
label="Add a character"
|
||||||
|
hint="Most outline questions resolve once you know who wants what."
|
||||||
|
onClick={() => setAddingCharacter(true)}
|
||||||
|
primary
|
||||||
|
/>
|
||||||
|
<ActionCard
|
||||||
|
icon={IconArrowRight}
|
||||||
|
label="Move to outlining"
|
||||||
|
hint="Once the shape is there, start turning notes into chapters."
|
||||||
|
onClick={() => update.mutate({ phase: 'Outlining' })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
|
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
|
||||||
<p className="mb-3 text-sm muted">
|
<p className="mb-3 text-sm muted">
|
||||||
@@ -43,14 +68,26 @@ function BrainstormingDashboard({ novel }: { novel: Novel }) {
|
|||||||
readOnly={!can('Write', novel)}
|
readOnly={!can('Write', novel)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{addingCharacter && (
|
||||||
|
<AddCharacterModal
|
||||||
|
novelId={novel.id}
|
||||||
|
onClose={() => setAddingCharacter(false)}
|
||||||
|
onCreated={(id) => navigate(`/novels/${novel.id}/characters/${id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OutliningDashboard({ novelId }: { novelId: string }) {
|
function WorkDashboard({ novelId }: { novelId: string }) {
|
||||||
|
const { data: novel } = useNovel(novelId)
|
||||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
|
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
|
||||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
|
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
|
||||||
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
|
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
|
||||||
const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId)
|
const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId)
|
||||||
|
const { can } = useAuth()
|
||||||
|
const canCreate = can('CreateContent', novel)
|
||||||
|
|
||||||
const recentCharacters = [...(characters ?? [])].sort(
|
const recentCharacters = [...(characters ?? [])].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
@@ -61,6 +98,8 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
|
{canCreate && <QuickActions novelId={novelId} lastChapter={recentChapters[0]} />}
|
||||||
|
|
||||||
<section id="dashboard-activity-graph" className="card p-5">
|
<section id="dashboard-activity-graph" className="card p-5">
|
||||||
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
|
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
|
||||||
|
|
||||||
@@ -174,6 +213,104 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuickActions({ novelId, lastChapter }: { novelId: string; lastChapter?: ChapterSummary }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createChapter = useCreateChapter(novelId)
|
||||||
|
const [addingCharacter, setAddingCharacter] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<ActionCard
|
||||||
|
icon={IconPlus}
|
||||||
|
label="New chapter"
|
||||||
|
hint="Start with a title — the outline can come later."
|
||||||
|
onClick={() =>
|
||||||
|
createChapter.mutate(
|
||||||
|
{ title: 'Untitled chapter' },
|
||||||
|
{ onSuccess: (chapter) => navigate(`chapters/${chapter.id}`) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
busy={createChapter.isPending}
|
||||||
|
primary
|
||||||
|
/>
|
||||||
|
<ActionCard icon={IconCharacters} label="New character" hint="Add someone new to the cast." onClick={() => setAddingCharacter(true)} />
|
||||||
|
{lastChapter ? (
|
||||||
|
<ActionCard to={`chapters/${lastChapter.id}`} icon={IconChapter} label="Continue writing" hint={lastChapter.title} />
|
||||||
|
) : (
|
||||||
|
<ActionCard to="chapters" icon={IconChapter} label="View chapters" hint="Nothing drafted yet." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createChapter.error && (
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<ErrorNote error={createChapter.error} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addingCharacter && (
|
||||||
|
<AddCharacterModal
|
||||||
|
novelId={novelId}
|
||||||
|
onClose={() => setAddingCharacter(false)}
|
||||||
|
onCreated={(id) => navigate(`characters/${id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionCard({
|
||||||
|
icon: ActionIcon,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
onClick,
|
||||||
|
primary,
|
||||||
|
busy,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
icon: typeof IconPlus
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
onClick?: () => void
|
||||||
|
primary?: boolean
|
||||||
|
busy?: boolean
|
||||||
|
to?: string
|
||||||
|
}) {
|
||||||
|
const className =
|
||||||
|
'card flex items-start gap-3 px-4 py-3.5 text-left transition hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60'
|
||||||
|
const style = primary ? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' } : undefined
|
||||||
|
const iconStyle = {
|
||||||
|
background: primary ? 'var(--accent)' : 'var(--surface-sunken)',
|
||||||
|
color: primary ? 'var(--accent-ink)' : 'var(--ink-muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg" style={iconStyle}>
|
||||||
|
<ActionIcon />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-semibold" style={primary ? { color: 'var(--accent)' } : undefined}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-sm muted">{hint}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className={className} style={style}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className={className} style={style} onClick={onClick} disabled={busy}>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TagCloud({ novelId, tags }: { novelId: string; tags: TagSummary[] }) {
|
function TagCloud({ novelId, tags }: { novelId: string; tags: TagSummary[] }) {
|
||||||
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
|
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user