Remove Scenes, group beats by multiple characters; strip comments repo-wide
Drop the Scene entity/grouping in favor of chapters carrying prose directly and beats belonging to many characters. Add markdown editor + character multi-select components to the web client. Remove all XML doc and inline comments across the touched C#/TS/CSS files in favor of self-documenting names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP server config, no secrets) and ignore .idea/.
This commit is contained in:
@@ -5,40 +5,26 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
|
||||
public record AgentToolResult(string Content, bool IsError);
|
||||
|
||||
/// <summary>
|
||||
/// A lookup a tool performed came back empty. Not an exception — the underlying service
|
||||
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
|
||||
/// recognises and turns into the same error-result shape a caught exception would produce.
|
||||
/// </summary>
|
||||
internal record ToolNotFound(string Message);
|
||||
|
||||
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
|
||||
public record AgentTool(
|
||||
string Name,
|
||||
string Description,
|
||||
JsonElement InputSchema,
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
/// <summary>
|
||||
/// The tools the writing agent can reach for. Everything here goes through the same
|
||||
/// application services the REST API uses, so an edit made by the agent is
|
||||
/// indistinguishable from one made in the UI.
|
||||
/// </summary>
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
BeatService beats,
|
||||
SceneService scenes,
|
||||
TagService tags,
|
||||
OpenQuestionService questions,
|
||||
ILogger<NovelAgentToolset> logger)
|
||||
@@ -56,10 +42,6 @@ public class NovelAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>
|
||||
/// Runs a tool and serialises its result. Failures come back as text rather than
|
||||
/// exceptions so the model can read the message and correct itself.
|
||||
/// </summary>
|
||||
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
@@ -95,16 +77,13 @@ public class NovelAgentToolset(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
|
||||
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
|
||||
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
/// <summary>Turns a nullable lookup into either the mapped response or a <see cref="ToolNotFound"/> the model can read.</summary>
|
||||
private static async Task<object> OrNotFound<TEntity, TResponse>(
|
||||
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
|
||||
await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
|
||||
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
|
||||
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
@@ -227,10 +206,9 @@ public class NovelAgentToolset(
|
||||
new CreateBeatRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.Guids(input, "character_ids"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
@@ -250,10 +228,9 @@ public class NovelAgentToolset(
|
||||
new UpdateBeatRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.Guids(input, "character_ids"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
|
||||
});
|
||||
|
||||
@@ -310,13 +287,13 @@ public class NovelAgentToolset(
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_chapters",
|
||||
"List the project's chapters in manuscript order with scene and word counts.",
|
||||
"List the project's chapters in manuscript order with beat and word counts.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter",
|
||||
"Read one chapter in full, including all of its scenes and any drafted prose.",
|
||||
"Read one chapter in full: its outline (beats) and its drafted prose.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
||||
.Build(),
|
||||
@@ -338,6 +315,7 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.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.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
@@ -349,11 +327,14 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_chapter",
|
||||
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
|
||||
"Revise a chapter's title, number, summary, POV, setting, notes, status or drafted "
|
||||
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
||||
+ "word count is recomputed automatically.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
@@ -364,6 +345,7 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.Str("prose", "The chapter's drafted text, in markdown.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
@@ -380,61 +362,10 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_scene",
|
||||
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
|
||||
+ "draftable later, so fill those in when the writer has given you enough to work with.",
|
||||
SceneSchema()
|
||||
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
|
||||
.Str("title", "Scene title.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
||||
return await OrNotFound(scenes.CreateAsync(
|
||||
chapterId,
|
||||
new CreateSceneRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct), s => s.ToResponse(), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_scene",
|
||||
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
|
||||
+ "the word count is recomputed automatically.",
|
||||
SceneSchema()
|
||||
.Str("scene_id", "Id of the scene to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var sceneId = JsonInput.RequiredGuid(input, "scene_id");
|
||||
return await OrNotFound(scenes.UpdateAsync(
|
||||
sceneId,
|
||||
new UpdateSceneRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status")), ct), s => s.ToResponse(), "Scene", sceneId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_character_beats",
|
||||
"Every beat this character appears in, across the whole book, in manuscript order. "
|
||||
@@ -651,21 +582,8 @@ public class NovelAgentToolset(
|
||||
private static JsonSchemaBuilder BeatSchema() =>
|
||||
new JsonSchemaBuilder()
|
||||
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
|
||||
.Str("character_id", "Id of the character whose beat this is.")
|
||||
.StringArray("character_ids", "Ids of the characters whose beat this is. Replaces the existing list.")
|
||||
.Str("what_happened", "The event itself.")
|
||||
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
|
||||
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
||||
|
||||
private static JsonSchemaBuilder SceneSchema() =>
|
||||
new JsonSchemaBuilder()
|
||||
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
|
||||
.Str("summary", "What happens in the scene.")
|
||||
.Str("goal", "What the POV character is trying to achieve.")
|
||||
.Str("conflict", "What stands in the way.")
|
||||
.Str("outcome", "How it lands, and what it costs.")
|
||||
.Str("pov_character_id", "Id of the point-of-view character.")
|
||||
.Str("location", "Where the scene takes place.")
|
||||
.Str("prose", "The drafted prose for this scene.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user