diff --git a/README.md b/README.md index f2b4788..28340a5 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ and everything else keeps working. ### Tests ```bash -dotnet test # 44 tests +dotnet test # 73 tests cd src/Novelly.Web && npm run build # typecheck + bundle ``` @@ -98,10 +98,12 @@ out of `appsettings.json` and use user-secrets or the environment. ## The data model ``` -Project ──┬── Character ── CharacterRelationship +Project ──┬── Character ──┬── CharacterRelationship + │ └── CharacterArcStage (the arc: flat, ordered) ├── Chapter ──┬── Beat (the outline: flat, ordered) │ └── Scene (the prose) ├── Tag (applied to characters, chapters and beats) + ├── OpenQuestion (attached to a chapter and/or a character) └── AgentConversation ── AgentMessage ``` @@ -124,6 +126,29 @@ is for working out what happens, and a scene is where you write it. A beat's `Sc the optional link between them, and it is nullable in both directions — deleting a scene ungroups its beats rather than deleting the plan. +**Main characters carry the book; supporting characters hold it up.** A character's +`Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they +play in the story, importance is how much of it they take, and a mentor can be either. +Characters start supporting and get promoted. The two together drive the ordering, so the +protagonist is always the first name on the list. + +**A main character's arc is a table, not a paragraph.** `ArcSummary` still holds the +sentence version; `CharacterArcStage` breaks the same change into ordered steps, each +optionally pinned to the chapter it lands in. It is the same flat-and-ordered shape as the +beat table, for the same reason. Nothing refuses an arc on a supporting character — +demoting someone should not delete their work. + +**The character page reads the outlines back.** `GET /api/characters/{id}/beats` returns +every beat a character appears in, in manuscript order, each carrying its chapter so the +page links straight into that chapter's outline. It is the dossier's reality check: what +they actually do on the page, as against what the sheet claims about them. + +**Open questions are what you have not decided.** A question hangs off a chapter outline, a +character, both, or neither. It can be resolved, reopened or deleted, and resolving can +append the decision to the notes of whatever it was attached to — so a settled question ends +up where you re-read it rather than in a list you have stopped looking at. Resolved +questions drop off the list unless you ask for them. + **Tags cross-reference the book.** A tag is scoped to one project, unique by name (case-insensitively), and can be attached to any character, chapter or beat. Applying an unknown tag by name creates it, so tagging is one action rather than two. `GET @@ -134,8 +159,9 @@ motif or a thread across all three kinds at once. `NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools Claude asks for, feeds every result back in a single user turn, and repeats until Claude -stops asking. It has 18 tools covering the brief, characters, chapter outlines (beats), scenes and -tags — all of them going through the same application services the REST API uses. +stops asking. It has 29 tools covering the brief, characters and their arcs, chapter outlines +(beats), scenes, tags and open questions — all of them going through the same application +services the REST API uses. A few deliberate choices worth knowing about: @@ -153,7 +179,7 @@ A few deliberate choices worth knowing about: ## The MCP server -A stdio MCP server exposing 26 tools over the same REST API. It holds no domain logic of +A stdio MCP server exposing 38 tools over the same REST API. It holds no domain logic of its own — it is a second front end, not a second implementation. Build it, then point your MCP client at the produced binary: @@ -190,6 +216,8 @@ rather than failing opaquely. | Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` | | Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` | | Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` | +| Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` | +| Questions | `GET\|POST /api/projects/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` | | Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` | `PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs index 03b1161..edc46c9 100644 --- a/src/Novelly.Api/Characters/CharacterService.cs +++ b/src/Novelly.Api/Characters/CharacterService.cs @@ -8,16 +8,30 @@ namespace Novelly.Api.Characters; public class CharacterService(INovelDbContext db, TagService tags) { + /// + /// Main characters first, then by the part they play, then by name. + /// + /// + /// The ordering is done in memory on purpose. Both enums are stored as text, so sorting + /// them in SQL sorts the spelling — which puts Deuteragonist above Protagonist and buries + /// the character the book is about. Sorting after materialising uses the declaration + /// order, which is the significance order these enums are written in. A project's cast is + /// small enough that this costs nothing. + /// public async Task> ListAsync(Guid projectId, CancellationToken ct = default) { var characters = await Query() .Where(c => c.ProjectId == projectId) - .OrderBy(c => c.Importance) - .ThenBy(c => c.Role) - .ThenBy(c => c.Name) .ToListAsync(ct); - return [.. characters.Select(c => c.ToDto())]; + return + [ + .. characters + .OrderBy(c => c.Importance) + .ThenBy(c => c.Role) + .ThenBy(c => c.Name) + .Select(c => c.ToDto()) + ]; } public async Task GetAsync(Guid id, CancellationToken ct = default) => diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 6cda65d..d88ae9e 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -2,12 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from './client' import type { AgentTurn, + ArcStage, Chapter, ChapterSummary, Character, + CharacterBeat, Conversation, ConversationSummary, Beat, + OpenQuestion, Project, ProjectSummary, Scene, @@ -21,7 +24,9 @@ export const keys = { characters: (projectId: string) => ['projects', projectId, 'characters'] as const, tags: (projectId: string) => ['projects', projectId, 'tags'] as const, tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const, + characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const, chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const, + questions: (projectId: string) => ['projects', projectId, 'questions'] as const, chapter: (id: string) => ['chapters', id] as const, conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const, conversation: (id: string) => ['conversations', id] as const, @@ -103,6 +108,128 @@ export function useDeleteCharacter(projectId: string) { }) } +/** + * Every beat this character appears in, across the whole book. Kept separate from the + * dossier because it is derived from the outlines — what they actually do on the page. + */ +export const useCharacterBeats = (characterId: string | undefined) => + useQuery({ + queryKey: keys.characterBeats(characterId ?? ''), + queryFn: () => api.get(`/api/characters/${characterId}/beats`), + enabled: Boolean(characterId), + }) + +// --- Character arcs ---------------------------------------------------------- + +export function useCreateArcStage(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ characterId, ...body }: { characterId: string; title: string; description?: string; chapterId?: string }) => + api.post(`/api/characters/${characterId}/arc`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +export function useUpdateArcStage(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; chapterId?: string }) => + api.patch(`/api/arc-stages/${id}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +export function useDeleteArcStage(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/arc-stages/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +export function useReorderArcStages(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) => + api.post(`/api/characters/${characterId}/arc/reorder`, { stageIds }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), + }) +} + +// --- Open questions ---------------------------------------------------------- + +/** + * The project's undecided questions. Filters narrow to one chapter outline or character; + * resolved ones are left out unless asked for, since the list is about what is still open. + */ +export const useOpenQuestions = ( + projectId: string, + filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {}, +) => { + const params = new URLSearchParams() + if (filter.chapterId) params.set('chapterId', filter.chapterId) + if (filter.characterId) params.set('characterId', filter.characterId) + if (filter.includeResolved) params.set('includeResolved', 'true') + const query = params.toString() + + return useQuery({ + queryKey: [...keys.questions(projectId), query] as const, + queryFn: () => + api.get(`/api/projects/${projectId}/questions${query ? `?${query}` : ''}`), + }) +} + +export function useRaiseQuestion(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) => + api.post(`/api/projects/${projectId}/questions`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), + }) +} + +export function useUpdateQuestion(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) => + api.patch(`/api/questions/${id}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), + }) +} + +/** + * Resolving can append the decision to the notes of whatever the question hangs off, so + * this invalidates the chapter and character caches as well as the question list. + */ +export function useResolveQuestion(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) => + api.post(`/api/questions/${id}/resolve`, { resolution, appendToNotes }), + onSuccess: (question) => { + qc.invalidateQueries({ queryKey: keys.questions(projectId) }) + qc.invalidateQueries({ queryKey: keys.characters(projectId) }) + if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) }) + }, + }) +} + +export function useReopenQuestion(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.post(`/api/questions/${id}/reopen`, {}), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), + }) +} + +export function useDeleteQuestion(projectId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/questions/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), + }) +} + // --- Tags -------------------------------------------------------------------- export const useTags = (projectId: string) => @@ -280,6 +407,7 @@ export function useSendAgentMessage(projectId: string) { qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) + qc.invalidateQueries({ queryKey: keys.questions(projectId) }) qc.invalidateQueries({ queryKey: keys.project(projectId) }) }, }) diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index ddc4047..f1d45bf 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -21,6 +21,11 @@ export const characterRoles: CharacterRole[] = [ 'Foil', ] +/** How much of the book a character carries. Separate from the part they play. */ +export type CharacterImportance = 'Main' | 'Supporting' + +export const characterImportances: CharacterImportance[] = ['Main', 'Supporting'] + export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Final' export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] @@ -104,11 +109,39 @@ export interface Relationship { description: string | null } +/** One step in a main character's arc. Flat and ordered, like a chapter's beats. */ +export interface ArcStage { + id: string + characterId: string + sortOrder: number + title: string + description: string | null + chapterId: string | null + chapterNumber: number | null + chapterTitle: string | null + updatedAt: string +} + +/** A beat a character appears in, carrying its chapter so the page can link into the outline. */ +export interface CharacterBeat { + id: string + chapterId: string + chapterNumber: number + chapterTitle: string + sortOrder: number + title: string + whatHappened: string | null + whatsNext: string | null + sceneId: string | null + sceneTitle: string | null +} + export interface Character { id: string projectId: string name: string role: CharacterRole + importance: CharacterImportance age: string | null pronouns: string | null occupation: string | null @@ -124,6 +157,7 @@ export interface Character { notes: string | null relationships: Relationship[] tags: Tag[] + arcStages: ArcStage[] updatedAt: string } @@ -169,6 +203,24 @@ export interface Chapter extends Omit { + e.preventDefault() + if (!title.trim()) return + create.mutate( + { characterId: character.id, title: title.trim() }, + { onSuccess: () => setTitle('') }, + ) + } + + const move = (index: number, delta: number) => { + const next = [...stages] + const [moved] = next.splice(index, 1) + next.splice(index + delta, 0, moved) + reorder.mutate({ characterId: character.id, stageIds: next.map((s) => s.id) }) + } + + return ( +
+
+

Arc

+ {character.importance !== 'Main' && ( + Usually kept for main characters + )} +
+

+ The changes {character.name} goes through, in order. Pin a stage to the chapter it + lands in and it links into that outline. +

+ + {stages.length > 0 && ( +
    + {stages.map((stage, index) => ( + 0} + canMoveDown={index < stages.length - 1} + onMove={(delta) => move(index, delta)} + /> + ))} +
+ )} + +
+ setTitle(e.target.value)} + /> + +
+ + {create.error && ( +
+ +
+ )} +
+ ) +} + +function ArcStageRow({ + projectId, + stage, + chapters, + canMoveUp, + canMoveDown, + onMove, +}: { + projectId: string + stage: ArcStage + chapters: { id: string; number: number; title: string }[] + canMoveUp: boolean + canMoveDown: boolean + onMove: (delta: number) => void +}) { + const update = useUpdateArcStage(projectId) + const remove = useDeleteArcStage(projectId) + + return ( +
  • +
    + {stage.sortOrder} + +
    + title.trim() && update.mutate({ id: stage.id, title })} + /> + update.mutate({ id: stage.id, description })} + /> + +
    + + + {stage.chapterId && ( + + Open outline + + )} +
    +
    + +
    + + + +
    +
    +
  • + ) +} diff --git a/src/Novelly.Web/src/components/CharacterBeats.tsx b/src/Novelly.Web/src/components/CharacterBeats.tsx new file mode 100644 index 0000000..ed7623b --- /dev/null +++ b/src/Novelly.Web/src/components/CharacterBeats.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router-dom' +import { useCharacterBeats } from '../api/hooks' +import { ErrorNote, Spinner } from './ui' + +/** + * Every beat this character appears in, in manuscript order. This is the dossier's + * reality check: what they actually do on the page, as opposed to what the sheet claims + * about them. Each row links into the beat's chapter outline. + */ +export function CharacterBeats({ + projectId, + characterId, + characterName, +}: { + projectId: string + characterId: string + characterName: string +}) { + const { data: beats, isPending, error } = useCharacterBeats(characterId) + + return ( +
    +
    +

    + Beats + {beats && beats.length > 0 && · {beats.length}} +

    +
    + + {isPending ? ( + + ) : error ? ( + + ) : beats?.length ? ( +
    + + + + + + + + + + + {beats.map((beat) => ( + + + + + + + ))} + +
    ChapterBeatWhat happenedWhat’s next
    + + {beat.chapterNumber}.{beat.sortOrder} + +
    {beat.chapterTitle}
    +
    {beat.title}{beat.whatHappened}{beat.whatsNext}
    +
    + ) : ( +

    + {characterName} is not on any beat yet. Assign them a beat in a chapter outline + and it shows up here. +

    + )} +
    + ) +} diff --git a/src/Novelly.Web/src/components/OpenQuestions.tsx b/src/Novelly.Web/src/components/OpenQuestions.tsx new file mode 100644 index 0000000..c580779 --- /dev/null +++ b/src/Novelly.Web/src/components/OpenQuestions.tsx @@ -0,0 +1,241 @@ +import { useState } from 'react' +import { + useDeleteQuestion, + useOpenQuestions, + useRaiseQuestion, + useReopenQuestion, + useResolveQuestion, +} from '../api/hooks' +import type { OpenQuestion } from '../api/types' +import { ErrorNote, Spinner } from './ui' + +/** + * The list of decisions still outstanding. The same section serves a chapter outline and + * a character page — `scope` decides both what it shows and what a new question is + * attached to, so raising one from the outline lands on that chapter without asking. + */ +export function OpenQuestions({ + projectId, + scope, +}: { + projectId: string + scope: { chapterId?: string; characterId?: string } +}) { + const [showResolved, setShowResolved] = useState(false) + const [asking, setAsking] = useState(false) + + const { data: questions, isPending, error } = useOpenQuestions(projectId, { + ...scope, + includeResolved: showResolved, + }) + const raise = useRaiseQuestion(projectId) + + const [question, setQuestion] = useState('') + const [detail, setDetail] = useState('') + + const submit = (e: React.FormEvent) => { + e.preventDefault() + if (!question.trim()) return + raise.mutate( + { question: question.trim(), detail: detail.trim() || undefined, ...scope }, + { + onSuccess: () => { + setQuestion('') + setDetail('') + setAsking(false) + }, + }, + ) + } + + const openCount = questions?.filter((q) => !q.isResolved).length ?? 0 + + return ( +
    +
    +

    + Open questions + {openCount > 0 && · {openCount}} +

    +
    + + +
    +
    + + {asking && ( +
    + setQuestion(e.target.value)} + /> +