Add character aliases/identity links, move-beats, keyboard help overlay

- Characters can carry aliases and be linked as the same underlying
  person (canonical SameCharacterAsId, optional reveal chapter/note),
  surfaced through the API, MCP tools, agent toolset, and web UI.
- Characters page redesigned as a filterable/sortable table (name+
  aliases, role, importance, occupation, tags) instead of a sidebar
  list, to stay usable as the cast grows.
- Beats can be moved between chapters (BeatService.MoveAsync + MCP/
  agent tool + endpoint).
- Add a keyboard-shortcuts help overlay (HelpButton/HelpOverlayContext)
  wired into the project layout.
- CLAUDE.md: require every frontend component to carry a unique id
  attribute; apply it to CharacterMultiSelect and MarkdownEditor.
This commit is contained in:
James Wampler
2026-08-17 17:26:50 -07:00
parent b4f4b35e3c
commit f124b9b4bb
32 changed files with 3002 additions and 356 deletions
+61 -3
View File
@@ -151,7 +151,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId));
yield return new AgentTool(
"update_character",
@@ -181,7 +182,42 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Character", characterId);
JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
"link_character_identity",
"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 project under another name. Both "
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
new JsonSchemaBuilder()
.Str("character_id", "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("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.")
.Str("note", "Context on the reveal, e.g. how and why the disguise held.")
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(characters.LinkIdentityAsync(
characterId,
new LinkCharacterIdentityRequest(
JsonInput.RequiredGuid(input, "same_character_as_id"),
JsonInput.Guid(input, "revealed_in_chapter_id"),
JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
"unlink_character_identity",
"Remove a character's identity link, restoring it to its own separate identity.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character to unlink.", required: true)
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId);
});
yield return new AgentTool(
@@ -289,6 +325,27 @@ public class NovelAgentToolset(
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
});
yield return new AgentTool(
"move_beats",
"Move one or more beats from one chapter to another, appending them to the target "
+ "chapter's end in the order given.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the beats' current chapter.", required: true)
.Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true)
.StringArray("beat_ids", "Ids of the beats to move.", required: true)
.Build(),
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(beats.MoveAsync(
chapterId,
new MoveBeatsRequest(
JsonInput.RequiredGuid(input, "target_chapter_id"),
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.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);
});
yield return new AgentTool(
"list_tags",
"List the project's tags with how many characters, chapters and beats carry each. "
@@ -596,7 +653,8 @@ public class NovelAgentToolset(
.Str("arc_summary", "How they change over the course of the book.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.StringArray("aliases", "Other names this character is known by. Replaces the existing aliases.");
}
private static JsonSchemaBuilder BeatSchema() =>