Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook
The layered split into Domain/Application/Infrastructure/Api was forcing organisation by layer: adding one capability meant touching four projects and four folders that each held a slice of it. Those four projects are now one feature-organised Novelly.Api, where each folder — Projects, Characters, Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and endpoints together. Common/ holds what genuinely crosses features (the patch semantics, the two exception types, DraftStatus) and Data/ holds the DbContext and migrations. Six .NET projects become five: the three layer projects are gone, and Novelly.AppHost and Novelly.ServiceDefaults are new. - Namespaces move from NovelSoftware.* to Novelly.*, including the entity type names recorded in the EF model snapshots. The migration ids are untouched, so an existing novel.db still migrates cleanly — verified against a fresh file. - Aspire orchestration mirrors the mic-check setup: the AppHost starts the API on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry, health checks and service discovery from ServiceDefaults. /health and /alive now answer in development. - A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web build. The scripts are plain bash so CI can run the same steps. - The MCP server's env var is now NOVELLY_API_URL. Verified beyond the build: 44 tests pass, the web client builds, the API was exercised over curl (project/chapter/beat/tag round trip, tag cross-reference, 503 on the agent without a key while conversation listing still returns 200), the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface the API's own message rather than being flattened), and the AppHost was run to confirm both resources come up and Vite proxies /api through to the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
30e0c6926e
commit
725758ccd9
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>A tool the model may call, described in the shape the Messages API expects.</summary>
|
||||
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema);
|
||||
|
||||
/// <summary>One content block in a model turn.</summary>
|
||||
public abstract record AgentContentBlock;
|
||||
|
||||
public record AgentTextBlock(string Text) : AgentContentBlock;
|
||||
|
||||
public record AgentToolUseBlock(string Id, string Name, JsonElement Input) : AgentContentBlock;
|
||||
|
||||
public record AgentToolResultBlock(string ToolUseId, string Content, bool IsError = false) : AgentContentBlock;
|
||||
|
||||
/// <summary>A full turn in the conversation sent to or received from the model.</summary>
|
||||
public record AgentChatMessage(string Role, IReadOnlyList<AgentContentBlock> Content)
|
||||
{
|
||||
public static AgentChatMessage User(params AgentContentBlock[] content) => new("user", content);
|
||||
public static AgentChatMessage Assistant(IReadOnlyList<AgentContentBlock> content) => new("assistant", content);
|
||||
}
|
||||
|
||||
public record AgentModelResponse(IReadOnlyList<AgentContentBlock> Content, string? StopReason);
|
||||
|
||||
/// <summary>
|
||||
/// The model-facing seam. Infrastructure implements this against the Anthropic SDK;
|
||||
/// tests substitute a scripted stand-in so the agent loop can be exercised offline.
|
||||
/// </summary>
|
||||
public interface IAgentModelClient
|
||||
{
|
||||
Task<AgentModelResponse> CompleteAsync(
|
||||
string systemPrompt,
|
||||
IReadOnlyList<AgentChatMessage> messages,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Configuration for the embedded writing agent.</summary>
|
||||
public class AgentOptions
|
||||
{
|
||||
public const string SectionName = "Agent";
|
||||
|
||||
/// <summary>Anthropic model id. Defaults to the current Opus.</summary>
|
||||
public string Model { get; set; } = "claude-opus-5";
|
||||
|
||||
public int MaxTokens { get; set; } = 16000;
|
||||
|
||||
/// <summary>Thinking depth: low | medium | high | xhigh | max.</summary>
|
||||
public string Effort { get; set; } = "high";
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on model round-trips per user turn. Each tool call costs one; without a
|
||||
/// cap a confused model could loop indefinitely.
|
||||
/// </summary>
|
||||
public int MaxIterations { get; set; } = 12;
|
||||
|
||||
/// <summary>Falls back to the ANTHROPIC_API_KEY environment variable when unset.</summary>
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>A chat thread between the writer and the embedded agent, scoped to one project.</summary>
|
||||
public class AgentConversation
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public string Title { get; set; } = "New conversation";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public List<AgentMessage> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One turn in an agent conversation. Assistant turns may carry a record of the tools
|
||||
/// the agent called, so the UI can show what it changed and the next request can replay
|
||||
/// the turn back to the model.
|
||||
/// </summary>
|
||||
public class AgentMessage
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ConversationId { get; set; }
|
||||
public AgentConversation? Conversation { get; set; }
|
||||
|
||||
public AgentRole Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Position in the conversation, 0-based. Timestamps are not enough to order a
|
||||
/// transcript: a fast turn can produce two messages inside the same tick.
|
||||
/// </summary>
|
||||
public int Sequence { get; set; }
|
||||
|
||||
/// <summary>The visible text of the turn.</summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON array of <c>{ name, input, result }</c> objects describing tool calls made
|
||||
/// during this turn. Null on user turns and on assistant turns that used no tools.
|
||||
/// </summary>
|
||||
public string? ToolCallsJson { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
public record ConversationSummaryDto(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
int MessageCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record ConversationDto(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
IReadOnlyList<AgentMessageDto> Messages,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record AgentMessageDto(
|
||||
Guid Id,
|
||||
AgentRole Role,
|
||||
string Content,
|
||||
IReadOnlyList<ToolCallDto> ToolCalls,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>A record of one tool the agent invoked, surfaced so the writer can audit changes.</summary>
|
||||
public record ToolCallDto(string Name, string Input, string Result);
|
||||
|
||||
public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
|
||||
|
||||
public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message);
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
public static class AgentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent");
|
||||
|
||||
projectScoped.MapGet("/conversations", async (
|
||||
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.ListConversationsAsync(projectId, ct)))
|
||||
.WithSummary("List the project's agent conversations.");
|
||||
|
||||
projectScoped.MapPost("/messages", async (
|
||||
Guid projectId,
|
||||
SendAgentMessageRequest request,
|
||||
NovelAgentService agent,
|
||||
CancellationToken ct) =>
|
||||
Results.Ok(await agent.SendMessageAsync(projectId, request, ct)))
|
||||
.WithSummary("Send a message to the writing agent and run it to completion.");
|
||||
|
||||
var conversations = app.MapGroup("/api/conversations").WithTags("Agent");
|
||||
|
||||
conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.GetConversationAsync(id, ct)))
|
||||
.WithSummary("Read a conversation's full transcript.");
|
||||
|
||||
conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
|
||||
{
|
||||
await agent.DeleteConversationAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a conversation.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>Who produced a message in an agent conversation.</summary>
|
||||
public enum AgentRole
|
||||
{
|
||||
User,
|
||||
Assistant
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Text.Json;
|
||||
using Anthropic.Models.Messages;
|
||||
using Anthropic;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to the Anthropic Messages API. Translates between the application's
|
||||
/// model-agnostic block types and the SDK's request/response shapes; the tool-use loop
|
||||
/// itself lives in <see cref="NovelAgentService"/>.
|
||||
/// </summary>
|
||||
public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentModelClient
|
||||
{
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
private AnthropicClient? _client;
|
||||
|
||||
/// <summary>
|
||||
/// Built on first use rather than at construction. This type is injected into the
|
||||
/// agent service, which also serves read-only endpoints like listing conversations —
|
||||
/// those should keep working on an install that has not set up a key yet.
|
||||
/// </summary>
|
||||
private AnthropicClient Client => _client ??= new AnthropicClient
|
||||
{
|
||||
ApiKey = _options.ApiKey
|
||||
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
|
||||
?? throw new AgentNotConfiguredException(
|
||||
"No Anthropic API key configured. Set the ANTHROPIC_API_KEY environment "
|
||||
+ "variable or the Agent:ApiKey setting, then restart the API.")
|
||||
};
|
||||
|
||||
public async Task<AgentModelResponse> CompleteAsync(
|
||||
string systemPrompt,
|
||||
IReadOnlyList<AgentChatMessage> messages,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var parameters = new MessageCreateParams
|
||||
{
|
||||
Model = _options.Model,
|
||||
MaxTokens = _options.MaxTokens,
|
||||
System = new List<TextBlockParam>
|
||||
{
|
||||
// The system prompt is stable across a conversation, so cache it: every
|
||||
// turn after the first reads it back at a tenth of the input price.
|
||||
new() { Text = systemPrompt, CacheControl = new CacheControlEphemeral() }
|
||||
},
|
||||
OutputConfig = new OutputConfig { Effort = ParseEffort(_options.Effort) },
|
||||
Tools = [.. tools.Select(ToSdkTool)],
|
||||
Messages = [.. messages.Select(ToSdkMessage)]
|
||||
};
|
||||
|
||||
var response = await Client.Messages.Create(parameters, cancellationToken: ct);
|
||||
|
||||
return new AgentModelResponse(
|
||||
[.. response.Content.Select(FromSdkBlock).OfType<AgentContentBlock>()],
|
||||
response.StopReason?.ToString());
|
||||
}
|
||||
|
||||
private static Effort ParseEffort(string effort) => effort.ToLowerInvariant() switch
|
||||
{
|
||||
"low" => Effort.Low,
|
||||
"medium" => Effort.Medium,
|
||||
"high" => Effort.High,
|
||||
"max" => Effort.Max,
|
||||
_ => Effort.High
|
||||
};
|
||||
|
||||
private static ToolUnion ToSdkTool(AgentToolDefinition definition)
|
||||
{
|
||||
var properties = new Dictionary<string, JsonElement>();
|
||||
if (definition.InputSchema.TryGetProperty("properties", out var props)
|
||||
&& props.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in props.EnumerateObject())
|
||||
{
|
||||
properties[property.Name] = property.Value;
|
||||
}
|
||||
}
|
||||
|
||||
List<string> required = [];
|
||||
if (definition.InputSchema.TryGetProperty("required", out var req)
|
||||
&& req.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
required = [.. req.EnumerateArray().Select(r => r.GetString()!).Where(r => r is not null)];
|
||||
}
|
||||
|
||||
return new Tool
|
||||
{
|
||||
Name = definition.Name,
|
||||
Description = definition.Description,
|
||||
InputSchema = new()
|
||||
{
|
||||
Properties = properties,
|
||||
Required = required
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static MessageParam ToSdkMessage(AgentChatMessage message) => new()
|
||||
{
|
||||
Role = message.Role == "assistant" ? Role.Assistant : Role.User,
|
||||
Content = new List<ContentBlockParam>([.. message.Content.Select(ToSdkBlock)])
|
||||
};
|
||||
|
||||
private static ContentBlockParam ToSdkBlock(AgentContentBlock block) => block switch
|
||||
{
|
||||
AgentTextBlock text => new TextBlockParam { Text = text.Text },
|
||||
|
||||
AgentToolUseBlock toolUse => new ToolUseBlockParam
|
||||
{
|
||||
ID = toolUse.Id,
|
||||
Name = toolUse.Name,
|
||||
Input = ToInputDictionary(toolUse.Input)
|
||||
},
|
||||
|
||||
AgentToolResultBlock result => new ToolResultBlockParam
|
||||
{
|
||||
ToolUseID = result.ToolUseId,
|
||||
Content = result.Content,
|
||||
IsError = result.IsError
|
||||
},
|
||||
|
||||
_ => throw new NotSupportedException($"Unsupported content block: {block.GetType().Name}")
|
||||
};
|
||||
|
||||
private static AgentContentBlock? FromSdkBlock(ContentBlock block)
|
||||
{
|
||||
if (block.TryPickText(out TextBlock? text))
|
||||
{
|
||||
return new AgentTextBlock(text!.Text);
|
||||
}
|
||||
|
||||
if (block.TryPickToolUse(out ToolUseBlock? toolUse))
|
||||
{
|
||||
return new AgentToolUseBlock(
|
||||
toolUse!.ID,
|
||||
toolUse.Name,
|
||||
JsonSerializer.SerializeToElement(toolUse.Input));
|
||||
}
|
||||
|
||||
// Thinking blocks and any future block types carry nothing the loop acts on.
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, JsonElement> ToInputDictionary(JsonElement input)
|
||||
{
|
||||
var dictionary = new Dictionary<string, JsonElement>();
|
||||
|
||||
if (input.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in input.EnumerateObject())
|
||||
{
|
||||
dictionary[property.Name] = property.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Small builder for the JSON Schema objects tool definitions need. Hand-writing these
|
||||
/// as string literals is where tool definitions usually rot, so build them structurally.
|
||||
/// </summary>
|
||||
public class JsonSchemaBuilder
|
||||
{
|
||||
private readonly JsonObject _properties = [];
|
||||
private readonly JsonArray _required = [];
|
||||
|
||||
public JsonSchemaBuilder Str(string name, string description, bool required = false) =>
|
||||
Add(name, "string", description, required);
|
||||
|
||||
public JsonSchemaBuilder Int(string name, string description, bool required = false) =>
|
||||
Add(name, "integer", description, required);
|
||||
|
||||
public JsonSchemaBuilder Bool(string name, string description, bool required = false) =>
|
||||
Add(name, "boolean", description, required);
|
||||
|
||||
public JsonSchemaBuilder StringArray(string name, string description, bool required = false)
|
||||
{
|
||||
_properties[name] = new JsonObject
|
||||
{
|
||||
["type"] = "array",
|
||||
["description"] = description,
|
||||
["items"] = new JsonObject { ["type"] = "string" }
|
||||
};
|
||||
|
||||
if (required)
|
||||
{
|
||||
_required.Add(name);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public JsonSchemaBuilder Enum(string name, string description, IEnumerable<string> values, bool required = false)
|
||||
{
|
||||
var node = new JsonObject
|
||||
{
|
||||
["type"] = "string",
|
||||
["description"] = description,
|
||||
["enum"] = new JsonArray([.. values.Select(v => JsonValue.Create(v))])
|
||||
};
|
||||
|
||||
_properties[name] = node;
|
||||
if (required)
|
||||
{
|
||||
_required.Add(name);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private JsonSchemaBuilder Add(string name, string type, string description, bool required)
|
||||
{
|
||||
_properties[name] = new JsonObject { ["type"] = type, ["description"] = description };
|
||||
if (required)
|
||||
{
|
||||
_required.Add(name);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public JsonElement Build()
|
||||
{
|
||||
var schema = new JsonObject
|
||||
{
|
||||
["type"] = "object",
|
||||
["properties"] = _properties,
|
||||
["required"] = _required
|
||||
};
|
||||
|
||||
return JsonSerializer.Deserialize<JsonElement>(schema.ToJsonString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lenient readers for tool input, which arrives as untyped JSON.</summary>
|
||||
public static class JsonInput
|
||||
{
|
||||
public static string? String(JsonElement input, string name) =>
|
||||
input.ValueKind == JsonValueKind.Object
|
||||
&& input.TryGetProperty(name, out var value)
|
||||
&& value.ValueKind is JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
public static string RequiredString(JsonElement input, string name) =>
|
||||
String(input, name) ?? throw new ArgumentException($"Missing required argument '{name}'.");
|
||||
|
||||
public static Guid? Guid(JsonElement input, string name) =>
|
||||
System.Guid.TryParse(String(input, name), out var id) ? id : null;
|
||||
|
||||
public static Guid RequiredGuid(JsonElement input, string name) =>
|
||||
Guid(input, name) ?? throw new ArgumentException($"Missing or malformed id argument '{name}'.");
|
||||
|
||||
public static int? Int(JsonElement input, string name)
|
||||
{
|
||||
if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when value.TryGetInt32(out var n) => n,
|
||||
JsonValueKind.String when int.TryParse(value.GetString(), out var n) => n,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an array of strings. Returns null when the property is absent, which the
|
||||
/// services read as "leave the existing list alone" — distinct from an empty array,
|
||||
/// which clears it.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string>? Strings(JsonElement input, string name)
|
||||
{
|
||||
if (input.ValueKind != JsonValueKind.Object
|
||||
|| !input.TryGetProperty(name, out var value)
|
||||
|| value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return [.. value.EnumerateArray()
|
||||
.Where(item => item.ValueKind == JsonValueKind.String)
|
||||
.Select(item => item.GetString()!)];
|
||||
}
|
||||
|
||||
public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
|
||||
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// The embedded writing agent. Runs the tool-use loop against the model, persists the
|
||||
/// conversation, and returns the finished turn together with a record of what it changed.
|
||||
/// </summary>
|
||||
public class NovelAgentService(
|
||||
INovelDbContext db,
|
||||
IAgentModelClient model,
|
||||
NovelAgentToolset toolset,
|
||||
IOptions<AgentOptions> options,
|
||||
ILogger<NovelAgentService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
|
||||
Guid projectId, CancellationToken ct = default) =>
|
||||
await db.Conversations
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
var conversation = await LoadConversationAsync(conversationId, ct);
|
||||
|
||||
return new ConversationDto(
|
||||
conversation.Id,
|
||||
conversation.ProjectId,
|
||||
conversation.Title,
|
||||
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)],
|
||||
conversation.UpdatedAt);
|
||||
}
|
||||
|
||||
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
var conversation = await LoadConversationAsync(conversationId, ct);
|
||||
db.Conversations.Remove(conversation);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message to the agent and runs it to completion, executing any tools it
|
||||
/// calls along the way. Returns the assistant's final turn.
|
||||
/// </summary>
|
||||
public async Task<AgentTurnDto> SendMessageAsync(
|
||||
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var conversation = request.ConversationId is { } id
|
||||
? await LoadConversationAsync(id, ct)
|
||||
: await StartConversationAsync(projectId, request.Message, ct);
|
||||
|
||||
// Persist the user's turn before running the loop. The tools save through the
|
||||
// same DbContext, so leaving this pending would entangle it with their writes —
|
||||
// and recording the question even if the model call fails is the behaviour we want.
|
||||
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||
|
||||
var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
|
||||
var transcript = BuildTranscript(conversation);
|
||||
var toolCalls = new List<ToolCallDto>();
|
||||
var text = new StringBuilder();
|
||||
|
||||
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
|
||||
{
|
||||
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
|
||||
|
||||
foreach (var block in response.Content.OfType<AgentTextBlock>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(block.Text))
|
||||
{
|
||||
text.AppendLine(block.Text.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
|
||||
if (requestedTools.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Echo the assistant's turn back verbatim, then answer every tool_use block in a
|
||||
// single user turn — splitting the results would train the model out of
|
||||
// requesting tools in parallel.
|
||||
transcript.Add(AgentChatMessage.Assistant(response.Content));
|
||||
|
||||
var results = new List<AgentContentBlock>();
|
||||
foreach (var call in requestedTools)
|
||||
{
|
||||
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"Agent tool {Tool} on project {ProjectId} {Outcome}",
|
||||
call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
|
||||
|
||||
toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), outcome.Content));
|
||||
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
|
||||
}
|
||||
|
||||
transcript.Add(AgentChatMessage.User([.. results]));
|
||||
|
||||
if (iteration == _options.MaxIterations - 1)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Agent hit the {Max}-iteration ceiling on project {ProjectId}",
|
||||
_options.MaxIterations, projectId);
|
||||
|
||||
text.AppendLine(
|
||||
"_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
|
||||
}
|
||||
}
|
||||
|
||||
var reply = await AppendMessageAsync(
|
||||
conversation,
|
||||
AgentRole.Assistant,
|
||||
text.ToString().TrimEnd(),
|
||||
toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null,
|
||||
ct);
|
||||
|
||||
return new AgentTurnDto(conversation.Id, ToDto(reply));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a turn and commits it. Messages are added to the set directly rather than
|
||||
/// through the parent's collection so their insert never depends on EF discovering
|
||||
/// the graph change at an inconvenient moment.
|
||||
/// </summary>
|
||||
private async Task<AgentMessage> AppendMessageAsync(
|
||||
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
||||
{
|
||||
var message = new AgentMessage
|
||||
{
|
||||
ConversationId = conversation.Id,
|
||||
Role = role,
|
||||
Sequence = conversation.Messages.Count == 0 ? 0 : conversation.Messages.Max(m => m.Sequence) + 1,
|
||||
Content = content,
|
||||
ToolCallsJson = toolCallsJson
|
||||
};
|
||||
|
||||
db.AgentMessages.Add(message);
|
||||
conversation.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// EF's relationship fixup normally puts the message into the parent's collection
|
||||
// once both are tracked. Guard rather than assume, since the sequence number of
|
||||
// the next turn is derived from it.
|
||||
if (!conversation.Messages.Contains(message))
|
||||
{
|
||||
conversation.Messages.Add(message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async Task<AgentConversation> StartConversationAsync(
|
||||
Guid projectId, string firstMessage, CancellationToken ct)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
var conversation = new AgentConversation
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Title = Summarise(firstMessage)
|
||||
};
|
||||
|
||||
db.Conversations.Add(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) =>
|
||||
await db.Conversations
|
||||
.Include(c => c.Messages)
|
||||
.FirstOrDefaultAsync(c => c.Id == conversationId, ct)
|
||||
?? throw new NotFoundException(nameof(AgentConversation), conversationId);
|
||||
|
||||
/// <summary>
|
||||
/// Replays the stored conversation as plain text turns. Tool calls are not replayed —
|
||||
/// the agent re-reads current state through its tools, which is more reliable than
|
||||
/// trusting a transcript of edits that may since have been changed in the UI.
|
||||
/// </summary>
|
||||
private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) =>
|
||||
[
|
||||
.. conversation.Messages
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Content))
|
||||
.OrderBy(m => m.Sequence)
|
||||
.Select(m => new AgentChatMessage(
|
||||
m.Role == AgentRole.User ? "user" : "assistant",
|
||||
[new AgentTextBlock(m.Content)]))
|
||||
];
|
||||
|
||||
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
|
||||
{
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct)
|
||||
?? throw new NotFoundException(nameof(Project), projectId);
|
||||
|
||||
var brief = new StringBuilder();
|
||||
brief.AppendLine($"Title: {project.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
|
||||
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
|
||||
return $"""
|
||||
You are a developmental editor and writing partner embedded in the software the
|
||||
writer is using to plan their novel. You have tools that read and write the
|
||||
project's real data: the brief, character dossiers, the outline tree, chapters
|
||||
and scenes.
|
||||
|
||||
The project you are working on:
|
||||
{brief}
|
||||
Working principles:
|
||||
|
||||
- Read before you write. Call get_project_brief, get_outline, or list_characters
|
||||
to ground yourself rather than assuming what is already there.
|
||||
- The book is the writer's. Ask about the choices that define the story — what a
|
||||
character wants, what the ending costs them — instead of deciding for them.
|
||||
- Do not invent biographical detail to fill an empty field. An unanswered
|
||||
question in a dossier is more useful than a plausible-sounding fabrication.
|
||||
- When you do have enough to act, act. Make the edit and say what you changed in
|
||||
a sentence; do not narrate every tool call or ask permission for routine work.
|
||||
- Prefer structural help — where a beat lands, whether a want and a need are
|
||||
genuinely in tension, what the outline is missing — over line-level polish,
|
||||
unless the writer asks for prose.
|
||||
- When drafting prose into a scene, match the voice already established in the
|
||||
project. Write the scene, then stop; do not append notes about your choices.
|
||||
- Destructive operations (deleting outline nodes) need the writer's explicit
|
||||
go-ahead first.
|
||||
|
||||
Keep replies short. Lead with the outcome, then the reasoning if it earns its place.
|
||||
""";
|
||||
}
|
||||
|
||||
private static AgentMessageDto ToDto(AgentMessage message) => new(
|
||||
message.Id,
|
||||
message.Role,
|
||||
message.Content,
|
||||
message.ToolCallsJson is null
|
||||
? []
|
||||
: JsonSerializer.Deserialize<List<ToolCallDto>>(message.ToolCallsJson, JsonOptions) ?? [],
|
||||
message.CreatedAt);
|
||||
|
||||
/// <summary>Derives a conversation title from its opening message.</summary>
|
||||
private static string Summarise(string message)
|
||||
{
|
||||
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
||||
return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "...");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
using System.Text.Json;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
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 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,
|
||||
ChapterService chapters,
|
||||
BeatService beats,
|
||||
SceneService scenes,
|
||||
TagService tags)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private Dictionary<string, AgentTool>? _byName;
|
||||
|
||||
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
||||
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>
|
||||
/// Runs a tool and serialises its result. Failures come back as text rather than
|
||||
/// exceptions so the model can read the message and correct itself.
|
||||
/// </summary>
|
||||
public async Task<AgentToolResult> ExecuteAsync(
|
||||
string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
{
|
||||
return new AgentToolResult($"No such tool: '{name}'.", true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await tool.Handler(projectId, input, ct);
|
||||
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
||||
|
||||
private IEnumerable<AgentTool> Build()
|
||||
{
|
||||
yield return new AgentTool(
|
||||
"get_project_brief",
|
||||
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
|
||||
+ "Call this first in a conversation to ground yourself in what the book is.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await projects.GetAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change; "
|
||||
+ "pass an empty string to clear a field.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
.Str("author", "Author name.")
|
||||
.Str("genre", "Genre or category.")
|
||||
.Str("logline", "One-sentence pitch.")
|
||||
.Str("synopsis", "Paragraph-length summary of the whole book.")
|
||||
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
||||
.Int("target_word_count", "Target manuscript length in words.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
JsonInput.String(input, "logline"),
|
||||
JsonInput.String(input, "synopsis"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Int(input, "target_word_count")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_characters",
|
||||
"List every character in the project with their full dossiers.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_character",
|
||||
"Add a character dossier. Name is the only requirement — leave fields blank when "
|
||||
+ "the writer has not decided them yet rather than inventing detail.",
|
||||
CharacterSchema(includeName: true, nameRequired: true).Build(),
|
||||
async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
JsonInput.String(input, "appearance"),
|
||||
JsonInput.String(input, "personality"),
|
||||
JsonInput.String(input, "backstory"),
|
||||
JsonInput.String(input, "want"),
|
||||
JsonInput.String(input, "need"),
|
||||
JsonInput.String(input, "internal_conflict"),
|
||||
JsonInput.String(input, "external_conflict"),
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_character",
|
||||
"Revise an existing character dossier. Only the fields you supply change.",
|
||||
CharacterSchema(includeName: true, nameRequired: false)
|
||||
.Str("character_id", "Id of the character to update.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await characters.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"),
|
||||
new UpdateCharacterRequest(
|
||||
JsonInput.String(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role"),
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
JsonInput.String(input, "appearance"),
|
||||
JsonInput.String(input, "personality"),
|
||||
JsonInput.String(input, "backstory"),
|
||||
JsonInput.String(input, "want"),
|
||||
JsonInput.String(input, "need"),
|
||||
JsonInput.String(input, "internal_conflict"),
|
||||
JsonInput.String(input, "external_conflict"),
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter_outline",
|
||||
"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.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_beat",
|
||||
"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.",
|
||||
BeatSchema()
|
||||
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
|
||||
.Str("title", "Three to five words naming the beat.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.CreateAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new CreateBeatRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_beat",
|
||||
"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.",
|
||||
BeatSchema()
|
||||
.Str("beat_id", "Id of the beat to update.", required: true)
|
||||
.Str("title", "Three to five words naming the beat.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "beat_id"),
|
||||
new UpdateBeatRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"delete_beat",
|
||||
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("beat_id", "Id of the beat to delete.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
|
||||
return new { deleted = true };
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"reorder_beats",
|
||||
"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.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
|
||||
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.ReorderAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new ReorderBeatsRequest(
|
||||
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
||||
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
||||
.Where(g => g != Guid.Empty)]), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_tags",
|
||||
"List the project'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.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_tag_references",
|
||||
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
|
||||
+ "trace a motif or a thread through the book.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("tag_id", "Id of the tag to trace.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_chapters",
|
||||
"List the project's chapters in manuscript order with scene and word counts.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter",
|
||||
"Read one chapter in full, including all of its scenes and any drafted prose.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_chapter",
|
||||
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "Chapter title.", required: true)
|
||||
.Int("number", "Position in the manuscript, 1-based.")
|
||||
.Str("summary", "What the chapter covers.")
|
||||
.Str("pov_character_id", "Id of the point-of-view character.")
|
||||
.Str("setting", "Where and when the chapter takes place.")
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_chapter",
|
||||
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
.Int("number", "Position in the manuscript.")
|
||||
.Str("summary", "What the chapter covers.")
|
||||
.Str("pov_character_id", "Id of the point-of-view character.")
|
||||
.Str("setting", "Where and when the chapter takes place.")
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await chapters.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new UpdateChapterRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_scene",
|
||||
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
|
||||
+ "draftable later, so fill those in when the writer has given you enough to work with.",
|
||||
SceneSchema()
|
||||
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
|
||||
.Str("title", "Scene title.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await scenes.CreateAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new CreateSceneRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_scene",
|
||||
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
|
||||
+ "the word count is recomputed automatically.",
|
||||
SceneSchema()
|
||||
.Str("scene_id", "Id of the scene to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await scenes.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "scene_id"),
|
||||
new UpdateSceneRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status")), ct));
|
||||
}
|
||||
|
||||
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
|
||||
{
|
||||
var schema = new JsonSchemaBuilder();
|
||||
|
||||
if (includeName)
|
||||
{
|
||||
schema.Str("name", "The character's name.", nameRequired);
|
||||
}
|
||||
|
||||
return schema
|
||||
.Enum("role", "The part they play in the story.", System.Enum.GetNames<CharacterRole>())
|
||||
.Str("age", "Age, exact or approximate.")
|
||||
.Str("pronouns", "The pronouns this character uses.")
|
||||
.Str("occupation", "What they do.")
|
||||
.Str("appearance", "How they look.")
|
||||
.Str("personality", "Temperament, habits, how they treat people.")
|
||||
.Str("backstory", "History that shapes who they are now.")
|
||||
.Str("want", "What they consciously pursue.")
|
||||
.Str("need", "What they actually need, usually at odds with what they want.")
|
||||
.Str("internal_conflict", "The war inside them.")
|
||||
.Str("external_conflict", "What in the world opposes them.")
|
||||
.Str("arc_summary", "How they change over the course of the book.")
|
||||
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
||||
}
|
||||
|
||||
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.")
|
||||
.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>());
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
/// <summary>
|
||||
/// One row of a chapter's outline: a short label, who it belongs to, what happened, and
|
||||
/// what it sets up. Beats are the planning layer — flat and ordered within a chapter,
|
||||
/// with no nesting. A beat may optionally be grouped under the <see cref="Scene"/> that
|
||||
/// will eventually carry its prose.
|
||||
/// </summary>
|
||||
public class Beat
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>Optional grouping: the scene this beat will be written into.</summary>
|
||||
public Guid? SceneId { get; set; }
|
||||
public Scene? Scene { get; set; }
|
||||
|
||||
/// <summary>Position within the chapter. Gaps are allowed.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>A three-to-five word handle for the beat, not a sentence.</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary>
|
||||
public Guid? CharacterId { get; set; }
|
||||
public Character? Character { get; set; }
|
||||
|
||||
/// <summary>The event itself.</summary>
|
||||
public string? WhatHappened { get; set; }
|
||||
|
||||
/// <summary>What it sets in motion — the hook into the next beat.</summary>
|
||||
public string? WhatsNext { get; set; }
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
public record BeatDto(
|
||||
Guid Id,
|
||||
Guid ChapterId,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
Guid? CharacterId,
|
||||
string? CharacterName,
|
||||
string? WhatHappened,
|
||||
string? WhatsNext,
|
||||
Guid? SceneId,
|
||||
string? SceneTitle,
|
||||
IReadOnlyList<TagDto> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateBeatRequest(
|
||||
string Title,
|
||||
int? SortOrder = null,
|
||||
Guid? CharacterId = null,
|
||||
string? WhatHappened = null,
|
||||
string? WhatsNext = null,
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the beat's tags outright.
|
||||
/// </summary>
|
||||
public record UpdateBeatRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
Guid? CharacterId = null,
|
||||
string? WhatHappened = null,
|
||||
string? WhatsNext = null,
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
|
||||
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
|
||||
|
||||
public static class BeatMapping
|
||||
{
|
||||
public static BeatDto ToDto(this Beat b) => new(
|
||||
b.Id,
|
||||
b.ChapterId,
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.CharacterId,
|
||||
b.Character?.Name,
|
||||
b.WhatHappened,
|
||||
b.WhatsNext,
|
||||
b.SceneId,
|
||||
b.Scene?.Title,
|
||||
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
|
||||
b.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
public static class BeatEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats");
|
||||
|
||||
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(chapterId, ct)))
|
||||
.WithSummary("Read a chapter's outline: its beats, in order.");
|
||||
|
||||
chapterScoped.MapPost("/", async (
|
||||
Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(chapterId, request, ct);
|
||||
return Results.Created($"/api/beats/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Add a beat to a chapter's outline.");
|
||||
|
||||
chapterScoped.MapPost("/reorder", async (
|
||||
Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ReorderAsync(chapterId, request, ct)))
|
||||
.WithSummary("Renumber a chapter's beats to match the order given.");
|
||||
|
||||
var beats = app.MapGroup("/api/beats").WithTags("Beats");
|
||||
|
||||
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
.WithSummary("Read one beat.");
|
||||
|
||||
beats.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Update a beat. Sending a tag list replaces the beat's tags.");
|
||||
|
||||
beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a beat.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
/// <summary>
|
||||
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
|
||||
/// here is scoped to one chapter.
|
||||
/// </summary>
|
||||
public class BeatService(INovelDbContext db, TagService tags)
|
||||
{
|
||||
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
var beats = await Query()
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
.OrderBy(b => b.SortOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. beats.Select(b => b.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
|
||||
?? throw new NotFoundException(nameof(Chapter), chapterId);
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
var beat = new Beat
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
||||
CharacterId = request.CharacterId,
|
||||
WhatHappened = request.WhatHappened,
|
||||
WhatsNext = request.WhatsNext,
|
||||
SceneId = request.SceneId
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
db.Beats.Add(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(beat.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var beat = await FindAsync(id, ct);
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct)
|
||||
?? throw new NotFoundException(nameof(Chapter), beat.ChapterId);
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
||||
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||
beat.CharacterId = request.CharacterId ?? beat.CharacterId;
|
||||
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
||||
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
||||
beat.SceneId = request.SceneId ?? beat.SceneId;
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var beat = await FindAsync(id, ct);
|
||||
db.Beats.Remove(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
|
||||
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
|
||||
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
|
||||
|
||||
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new NotFoundException(nameof(Beat), missing[0]);
|
||||
}
|
||||
|
||||
// Listed beats take the order given; anything omitted keeps its relative position
|
||||
// after them rather than silently jumping to the front.
|
||||
var order = 1;
|
||||
foreach (var id in request.BeatIds)
|
||||
{
|
||||
beats.Single(b => b.Id == id).SortOrder = order++;
|
||||
}
|
||||
|
||||
foreach (var beat in beats.Where(b => !request.BeatIds.Contains(b.Id)).OrderBy(b => b.SortOrder))
|
||||
{
|
||||
beat.SortOrder = order++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(
|
||||
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
|
||||
{
|
||||
if (characterId is { } cid)
|
||||
{
|
||||
var belongs = await db.Characters
|
||||
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A beat's character must belong to the same project as its chapter.");
|
||||
}
|
||||
}
|
||||
|
||||
if (sceneId is { } sid)
|
||||
{
|
||||
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A beat can only be grouped under a scene in the same chapter.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
{
|
||||
var max = await db.Beats
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
.MaxAsync(b => (int?)b.SortOrder, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
db.Beats
|
||||
.Include(b => b.Character)
|
||||
.Include(b => b.Scene)
|
||||
.Include(b => b.Tags);
|
||||
|
||||
private async Task<Beat> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(b => b.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Beat), id);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
/// <summary>Position in the manuscript, 1-based.</summary>
|
||||
public int Number { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The paragraph that opens the chapter's outline, above the beat table.
|
||||
/// </summary>
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>Whose head we are in for this chapter.</summary>
|
||||
public Guid? PovCharacterId { get; set; }
|
||||
public Character? PovCharacter { get; set; }
|
||||
|
||||
public string? Setting { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||
public int? TargetWordCount { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
|
||||
public List<Scene> Scenes { get; set; } = [];
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
public record ChapterSummaryDto(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
Guid? PovCharacterId,
|
||||
string? PovCharacterName,
|
||||
string? Setting,
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
int BeatCount,
|
||||
int SceneCount,
|
||||
int WordCount,
|
||||
IReadOnlyList<TagDto> Tags);
|
||||
|
||||
/// <summary>
|
||||
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
|
||||
/// and the prose layer (scenes).
|
||||
/// </summary>
|
||||
public record ChapterDto(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
Guid? PovCharacterId,
|
||||
string? PovCharacterName,
|
||||
string? Setting,
|
||||
string? Notes,
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
IReadOnlyList<BeatDto> Beats,
|
||||
IReadOnlyList<SceneDto> Scenes,
|
||||
IReadOnlyList<TagDto> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateChapterRequest(
|
||||
string Title,
|
||||
int? Number = null,
|
||||
string? Summary = null,
|
||||
Guid? PovCharacterId = null,
|
||||
string? Setting = null,
|
||||
string? Notes = null,
|
||||
DraftStatus Status = DraftStatus.Planned,
|
||||
int? TargetWordCount = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the chapter's tags outright.
|
||||
/// </summary>
|
||||
public record UpdateChapterRequest(
|
||||
string? Title = null,
|
||||
int? Number = null,
|
||||
string? Summary = null,
|
||||
Guid? PovCharacterId = null,
|
||||
string? Setting = null,
|
||||
string? Notes = null,
|
||||
DraftStatus? Status = null,
|
||||
int? TargetWordCount = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public static class ChapterMapping
|
||||
{
|
||||
public static ChapterDto ToDto(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
|
||||
c.Status, c.TargetWordCount,
|
||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToDto())],
|
||||
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
|
||||
c.UpdatedAt);
|
||||
|
||||
public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
|
||||
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())]);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
public static class ChapterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters");
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
.WithSummary("List a project's chapters in manuscript order.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(projectId, request, ct);
|
||||
return Results.Created($"/api/chapters/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Add a chapter.");
|
||||
|
||||
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters");
|
||||
|
||||
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
.WithSummary("Read a chapter with all of its scenes.");
|
||||
|
||||
chapters.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Update a chapter.");
|
||||
|
||||
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a chapter and its scenes.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
public class ChapterService(INovelDbContext db, TagService tags)
|
||||
{
|
||||
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var chapters = await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Scenes)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. chapters.Select(c => c.ToSummaryDto())];
|
||||
}
|
||||
|
||||
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
var chapter = new Chapter
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Title = request.Title,
|
||||
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
|
||||
Summary = request.Summary,
|
||||
PovCharacterId = request.PovCharacterId,
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
Status = request.Status,
|
||||
TargetWordCount = request.TargetWordCount
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
}
|
||||
|
||||
db.Chapters.Add(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(chapter.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var chapter = await FindAsync(id, ct);
|
||||
|
||||
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
||||
chapter.Number = request.Number ?? chapter.Number;
|
||||
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
|
||||
chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId;
|
||||
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
|
||||
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
||||
chapter.Status = request.Status ?? chapter.Status;
|
||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var chapter = await FindAsync(id, ct);
|
||||
db.Chapters.Remove(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
|
||||
{
|
||||
var max = await db.Chapters
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.MaxAsync(c => (int?)c.Number, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Character)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Scene)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
|
||||
.Include(c => c.Tags)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Chapter), id);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
/// <summary>
|
||||
/// A character dossier. Every field beyond <see cref="Name"/> is optional so a writer can
|
||||
/// start with a name and fill the sheet in as the character comes into focus.
|
||||
/// </summary>
|
||||
public class Character
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public CharacterRole Role { get; set; } = CharacterRole.Supporting;
|
||||
|
||||
public string? Age { get; set; }
|
||||
public string? Pronouns { get; set; }
|
||||
public string? Occupation { get; set; }
|
||||
|
||||
public string? Appearance { get; set; }
|
||||
public string? Personality { get; set; }
|
||||
public string? Backstory { get; set; }
|
||||
|
||||
/// <summary>What the character consciously wants.</summary>
|
||||
public string? Want { get; set; }
|
||||
|
||||
/// <summary>What the character actually needs — usually at odds with <see cref="Want"/>.</summary>
|
||||
public string? Need { get; set; }
|
||||
|
||||
public string? InternalConflict { get; set; }
|
||||
public string? ExternalConflict { get; set; }
|
||||
|
||||
/// <summary>How the character changes over the course of the book.</summary>
|
||||
public string? ArcSummary { get; set; }
|
||||
|
||||
/// <summary>Speech patterns, verbal tics, register — anything that makes dialogue sound like them.</summary>
|
||||
public string? Voice { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public List<CharacterRelationship> Relationships { get; set; } = [];
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A directed relationship from one character to another.</summary>
|
||||
public class CharacterRelationship
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid CharacterId { get; set; }
|
||||
public Character? Character { get; set; }
|
||||
|
||||
public Guid RelatedCharacterId { get; set; }
|
||||
public Character? RelatedCharacter { get; set; }
|
||||
|
||||
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
|
||||
public string RelationshipType { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
public record CharacterDto(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
string Name,
|
||||
CharacterRole Role,
|
||||
string? Age,
|
||||
string? Pronouns,
|
||||
string? Occupation,
|
||||
string? Appearance,
|
||||
string? Personality,
|
||||
string? Backstory,
|
||||
string? Want,
|
||||
string? Need,
|
||||
string? InternalConflict,
|
||||
string? ExternalConflict,
|
||||
string? ArcSummary,
|
||||
string? Voice,
|
||||
string? Notes,
|
||||
IReadOnlyList<RelationshipDto> Relationships,
|
||||
IReadOnlyList<TagDto> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record RelationshipDto(
|
||||
Guid Id,
|
||||
Guid RelatedCharacterId,
|
||||
string RelatedCharacterName,
|
||||
string RelationshipType,
|
||||
string? Description);
|
||||
|
||||
public record CreateCharacterRequest(
|
||||
string Name,
|
||||
CharacterRole Role = CharacterRole.Supporting,
|
||||
string? Age = null,
|
||||
string? Pronouns = null,
|
||||
string? Occupation = null,
|
||||
string? Appearance = null,
|
||||
string? Personality = null,
|
||||
string? Backstory = null,
|
||||
string? Want = null,
|
||||
string? Need = null,
|
||||
string? InternalConflict = null,
|
||||
string? ExternalConflict = null,
|
||||
string? ArcSummary = null,
|
||||
string? Voice = null,
|
||||
string? Notes = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the character's tags outright.
|
||||
/// </summary>
|
||||
public record UpdateCharacterRequest(
|
||||
string? Name = null,
|
||||
CharacterRole? Role = null,
|
||||
string? Age = null,
|
||||
string? Pronouns = null,
|
||||
string? Occupation = null,
|
||||
string? Appearance = null,
|
||||
string? Personality = null,
|
||||
string? Backstory = null,
|
||||
string? Want = null,
|
||||
string? Need = null,
|
||||
string? InternalConflict = null,
|
||||
string? ExternalConflict = null,
|
||||
string? ArcSummary = null,
|
||||
string? Voice = null,
|
||||
string? Notes = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public record CreateRelationshipRequest(
|
||||
Guid RelatedCharacterId,
|
||||
string RelationshipType,
|
||||
string? Description = null);
|
||||
|
||||
public static class CharacterMapping
|
||||
{
|
||||
public static CharacterDto ToDto(this Character c) => new(
|
||||
c.Id, c.ProjectId, c.Name, c.Role, c.Age, c.Pronouns, c.Occupation,
|
||||
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
|
||||
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
|
||||
[.. c.Relationships.Select(r => new RelationshipDto(
|
||||
r.Id,
|
||||
r.RelatedCharacterId,
|
||||
r.RelatedCharacter?.Name ?? "(unknown)",
|
||||
r.RelationshipType,
|
||||
r.Description))],
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
|
||||
c.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
public static class CharacterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters");
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
.WithSummary("List a project's character dossiers.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(projectId, request, ct);
|
||||
return Results.Created($"/api/characters/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Add a character dossier.");
|
||||
|
||||
var characters = app.MapGroup("/api/characters").WithTags("Characters");
|
||||
|
||||
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
.WithSummary("Read a character dossier.");
|
||||
|
||||
characters.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Update a character dossier.");
|
||||
|
||||
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a character.");
|
||||
|
||||
characters.MapPost("/{id:guid}/relationships", async (
|
||||
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.AddRelationshipAsync(id, request, ct)))
|
||||
.WithSummary("Relate this character to another in the same project.");
|
||||
|
||||
characters.MapDelete("/relationships/{relationshipId:guid}", async (
|
||||
Guid relationshipId, CharacterService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.RemoveRelationshipAsync(relationshipId, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Remove a relationship.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
/// <summary>The role a character plays in the story.</summary>
|
||||
public enum CharacterRole
|
||||
{
|
||||
Protagonist,
|
||||
Antagonist,
|
||||
Deuteragonist,
|
||||
Supporting,
|
||||
Minor,
|
||||
Mentor,
|
||||
LoveInterest,
|
||||
Foil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterService(INovelDbContext db, TagService tags)
|
||||
{
|
||||
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var characters = await Query()
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.OrderBy(c => c.Role)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. characters.Select(c => c.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureProjectExists(projectId, ct);
|
||||
|
||||
var character = new Character
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Age = request.Age,
|
||||
Pronouns = request.Pronouns,
|
||||
Occupation = request.Occupation,
|
||||
Appearance = request.Appearance,
|
||||
Personality = request.Personality,
|
||||
Backstory = request.Backstory,
|
||||
Want = request.Want,
|
||||
Need = request.Need,
|
||||
InternalConflict = request.InternalConflict,
|
||||
ExternalConflict = request.ExternalConflict,
|
||||
ArcSummary = request.ArcSummary,
|
||||
Voice = request.Voice,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
character.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
}
|
||||
|
||||
db.Characters.Add(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(character.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var character = await FindAsync(id, ct);
|
||||
|
||||
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
|
||||
character.Role = request.Role ?? character.Role;
|
||||
character.Age = Patch.Apply(character.Age, request.Age);
|
||||
character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns);
|
||||
character.Occupation = Patch.Apply(character.Occupation, request.Occupation);
|
||||
character.Appearance = Patch.Apply(character.Appearance, request.Appearance);
|
||||
character.Personality = Patch.Apply(character.Personality, request.Personality);
|
||||
character.Backstory = Patch.Apply(character.Backstory, request.Backstory);
|
||||
character.Want = Patch.Apply(character.Want, request.Want);
|
||||
character.Need = Patch.Apply(character.Need, request.Need);
|
||||
character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict);
|
||||
character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict);
|
||||
character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary);
|
||||
character.Voice = Patch.Apply(character.Voice, request.Voice);
|
||||
character.Notes = Patch.Apply(character.Notes, request.Notes);
|
||||
character.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var character = await FindAsync(id, ct);
|
||||
db.Characters.Remove(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> AddRelationshipAsync(
|
||||
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var character = await FindAsync(characterId, ct);
|
||||
|
||||
var related = await db.Characters
|
||||
.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct)
|
||||
?? throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
|
||||
|
||||
if (related.ProjectId != character.ProjectId)
|
||||
{
|
||||
throw new InvalidOperationException("Characters must belong to the same project to be related.");
|
||||
}
|
||||
|
||||
db.CharacterRelationships.Add(new CharacterRelationship
|
||||
{
|
||||
CharacterId = characterId,
|
||||
RelatedCharacterId = request.RelatedCharacterId,
|
||||
RelationshipType = request.RelationshipType,
|
||||
Description = request.Description
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(characterId, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
|
||||
{
|
||||
var relationship = await db.CharacterRelationships
|
||||
.FirstOrDefaultAsync(r => r.Id == relationshipId, ct)
|
||||
?? throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
|
||||
|
||||
db.CharacterRelationships.Remove(relationship);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private IQueryable<Character> Query() =>
|
||||
db.Characters
|
||||
.Include(c => c.Relationships)
|
||||
.ThenInclude(r => r.RelatedCharacter)
|
||||
.Include(c => c.Tags);
|
||||
|
||||
private async Task<Character> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Character), id);
|
||||
|
||||
private async Task EnsureProjectExists(Guid projectId, CancellationToken ct)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the agent is asked to run but has no model credentials. This is a
|
||||
/// deployment problem rather than a bad request, so the API reports it as 503 — the rest
|
||||
/// of the app works fine without a key.
|
||||
/// </summary>
|
||||
public class AgentNotConfiguredException(string message) : Exception(message);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
|
||||
public enum DraftStatus
|
||||
{
|
||||
Planned,
|
||||
Outlined,
|
||||
Drafted,
|
||||
Revised,
|
||||
Final
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a service is asked for an entity that does not exist. The API translates
|
||||
/// this into a 404 so services never have to know about HTTP.
|
||||
/// </summary>
|
||||
public class NotFoundException(string entity, Guid id)
|
||||
: Exception($"{entity} '{id}' was not found.")
|
||||
{
|
||||
public string Entity { get; } = entity;
|
||||
public Guid Id { get; } = id;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Agent;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Wires up every feature's services in one place. Endpoints, the embedded agent and the
|
||||
/// MCP server all resolve the same instances, so a capability added here is available to
|
||||
/// all three.
|
||||
/// </summary>
|
||||
public static class NovellyServiceRegistration
|
||||
{
|
||||
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("Novel")
|
||||
?? "Data Source=novel.db";
|
||||
|
||||
services.AddDbContext<NovelDbContext>(options => options.UseSqlite(connectionString));
|
||||
services.AddScoped<INovelDbContext>(sp => sp.GetRequiredService<NovelDbContext>());
|
||||
|
||||
services.AddScoped<ProjectService>();
|
||||
services.AddScoped<CharacterService>();
|
||||
services.AddScoped<BeatService>();
|
||||
services.AddScoped<TagService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<SceneService>();
|
||||
services.AddScoped<NovelAgentToolset>();
|
||||
services.AddScoped<NovelAgentService>();
|
||||
|
||||
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
||||
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Patch semantics shared by every update endpoint: a null value leaves the field
|
||||
/// untouched, an empty string clears it.
|
||||
/// </summary>
|
||||
internal static class Patch
|
||||
{
|
||||
public static string? Apply(string? current, string? incoming) => incoming switch
|
||||
{
|
||||
null => current,
|
||||
"" => null,
|
||||
_ => incoming
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Agent;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The persistence surface the application services depend on. Infrastructure supplies
|
||||
/// the EF Core implementation; tests can point it at an in-memory SQLite connection.
|
||||
/// </summary>
|
||||
public interface INovelDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
DbSet<Character> Characters { get; }
|
||||
DbSet<CharacterRelationship> CharacterRelationships { get; }
|
||||
DbSet<Beat> Beats { get; }
|
||||
DbSet<Tag> Tags { get; }
|
||||
DbSet<Chapter> Chapters { get; }
|
||||
DbSet<Scene> Scenes { get; }
|
||||
DbSet<AgentConversation> Conversations { get; }
|
||||
DbSet<AgentMessage> AgentMessages { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Novelly.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(NovelDbContext))]
|
||||
[Migration("20260806023249_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RelationshipType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RelatedCharacterId");
|
||||
|
||||
b.ToTable("CharacterRelationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.OutlineNode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NodeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.HasIndex("ProjectId", "ParentId", "SortOrder");
|
||||
|
||||
b.ToTable("OutlineNodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Conflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Conversations")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ConversationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Conversation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Characters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("RelatedCharacterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("RelatedCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.OutlineNode", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Chapters.OutlineNode", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("OutlineNodes")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Parent");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany("Scenes")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Navigation("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.OutlineNode", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
b.Navigation("Characters");
|
||||
|
||||
b.Navigation("Conversations");
|
||||
|
||||
b.Navigation("OutlineNodes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Projects",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Author = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Genre = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Logline = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Synopsis = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
TargetWordCount = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Projects", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Characters",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Age = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Pronouns = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Occupation = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Appearance = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Personality = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Backstory = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Want = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Need = table.Column<string>(type: "TEXT", nullable: true),
|
||||
InternalConflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ExternalConflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ArcSummary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Voice = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Characters", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Characters_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Conversations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Conversations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Conversations_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Chapters",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Number = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Setting = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
TargetWordCount = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Chapters", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Chapters_Characters_PovCharacterId",
|
||||
column: x => x.PovCharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Chapters_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CharacterRelationships",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
RelatedCharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
RelationshipType = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CharacterRelationships", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CharacterRelationships_Characters_CharacterId",
|
||||
column: x => x.CharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_CharacterRelationships_Characters_RelatedCharacterId",
|
||||
column: x => x.RelatedCharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AgentMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ConversationId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Role = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
|
||||
Sequence = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Content = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ToolCallsJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AgentMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AgentMessages_Conversations_ConversationId",
|
||||
column: x => x.ConversationId,
|
||||
principalTable: "Conversations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OutlineNodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ParentId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
NodeType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OutlineNodes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OutlineNodes_Chapters_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_OutlineNodes_OutlineNodes_ParentId",
|
||||
column: x => x.ParentId,
|
||||
principalTable: "OutlineNodes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_OutlineNodes_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Scenes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Goal = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Conflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Outcome = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Location = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Prose = table.Column<string>(type: "TEXT", nullable: true),
|
||||
WordCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Scenes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Scenes_Chapters_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Scenes_Characters_PovCharacterId",
|
||||
column: x => x.PovCharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AgentMessages_ConversationId_Sequence",
|
||||
table: "AgentMessages",
|
||||
columns: new[] { "ConversationId", "Sequence" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Chapters_PovCharacterId",
|
||||
table: "Chapters",
|
||||
column: "PovCharacterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Chapters_ProjectId_Number",
|
||||
table: "Chapters",
|
||||
columns: new[] { "ProjectId", "Number" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CharacterRelationships_CharacterId",
|
||||
table: "CharacterRelationships",
|
||||
column: "CharacterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CharacterRelationships_RelatedCharacterId",
|
||||
table: "CharacterRelationships",
|
||||
column: "RelatedCharacterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Characters_ProjectId",
|
||||
table: "Characters",
|
||||
column: "ProjectId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Conversations_ProjectId",
|
||||
table: "Conversations",
|
||||
column: "ProjectId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OutlineNodes_ChapterId",
|
||||
table: "OutlineNodes",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OutlineNodes_ParentId",
|
||||
table: "OutlineNodes",
|
||||
column: "ParentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OutlineNodes_ProjectId_ParentId_SortOrder",
|
||||
table: "OutlineNodes",
|
||||
columns: new[] { "ProjectId", "ParentId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Scenes_ChapterId_SortOrder",
|
||||
table: "Scenes",
|
||||
columns: new[] { "ChapterId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Scenes_PovCharacterId",
|
||||
table: "Scenes",
|
||||
column: "PovCharacterId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AgentMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CharacterRelationships");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OutlineNodes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Scenes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Conversations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Chapters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Characters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Projects");
|
||||
}
|
||||
}
|
||||
}
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Novelly.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(NovelDbContext))]
|
||||
[Migration("20260806031243_ReplaceOutlineWithBeatsAndTags")]
|
||||
partial class ReplaceOutlineWithBeatsAndTags
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("BeatTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("ChaptersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ChaptersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CharactersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("CharacterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SceneId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("WhatHappened")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WhatsNext")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("SceneId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Beats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RelationshipType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RelatedCharacterId");
|
||||
|
||||
b.ToTable("CharacterRelationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Conflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BeatsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChaptersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CharactersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Conversations")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ConversationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Conversation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany("Beats")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany()
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Scenes.Scene", "Scene")
|
||||
.WithMany()
|
||||
.HasForeignKey("SceneId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("Scene");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Characters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("RelatedCharacterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("RelatedCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany("Scenes")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Navigation("Beats");
|
||||
|
||||
b.Navigation("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
b.Navigation("Characters");
|
||||
|
||||
b.Navigation("Conversations");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReplaceOutlineWithBeatsAndTags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OutlineNodes");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Beats",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
SceneId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
CharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
WhatHappened = table.Column<string>(type: "TEXT", nullable: true),
|
||||
WhatsNext = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Beats", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Beats_Chapters_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Beats_Characters_CharacterId",
|
||||
column: x => x.CharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Beats_Scenes_SceneId",
|
||||
column: x => x.SceneId,
|
||||
principalTable: "Scenes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tags",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Color = table.Column<string>(type: "TEXT", maxLength: 16, nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tags", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Tags_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BeatTags",
|
||||
columns: table => new
|
||||
{
|
||||
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BeatTags", x => new { x.BeatsId, x.TagsId });
|
||||
table.ForeignKey(
|
||||
name: "FK_BeatTags_Beats_BeatsId",
|
||||
column: x => x.BeatsId,
|
||||
principalTable: "Beats",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_BeatTags_Tags_TagsId",
|
||||
column: x => x.TagsId,
|
||||
principalTable: "Tags",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChapterTags",
|
||||
columns: table => new
|
||||
{
|
||||
ChaptersId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChapterTags", x => new { x.ChaptersId, x.TagsId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ChapterTags_Chapters_ChaptersId",
|
||||
column: x => x.ChaptersId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChapterTags_Tags_TagsId",
|
||||
column: x => x.TagsId,
|
||||
principalTable: "Tags",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CharacterTags",
|
||||
columns: table => new
|
||||
{
|
||||
CharactersId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CharacterTags", x => new { x.CharactersId, x.TagsId });
|
||||
table.ForeignKey(
|
||||
name: "FK_CharacterTags_Characters_CharactersId",
|
||||
column: x => x.CharactersId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_CharacterTags_Tags_TagsId",
|
||||
column: x => x.TagsId,
|
||||
principalTable: "Tags",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Beats_ChapterId_SortOrder",
|
||||
table: "Beats",
|
||||
columns: new[] { "ChapterId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Beats_CharacterId",
|
||||
table: "Beats",
|
||||
column: "CharacterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Beats_SceneId",
|
||||
table: "Beats",
|
||||
column: "SceneId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BeatTags_TagsId",
|
||||
table: "BeatTags",
|
||||
column: "TagsId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChapterTags_TagsId",
|
||||
table: "ChapterTags",
|
||||
column: "TagsId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CharacterTags_TagsId",
|
||||
table: "CharacterTags",
|
||||
column: "TagsId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tags_ProjectId_Name",
|
||||
table: "Tags",
|
||||
columns: new[] { "ProjectId", "Name" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BeatTags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChapterTags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CharacterTags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Beats");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tags");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OutlineNodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
ParentId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
NodeType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OutlineNodes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OutlineNodes_Chapters_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_OutlineNodes_OutlineNodes_ParentId",
|
||||
column: x => x.ParentId,
|
||||
principalTable: "OutlineNodes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_OutlineNodes_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OutlineNodes_ChapterId",
|
||||
table: "OutlineNodes",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OutlineNodes_ParentId",
|
||||
table: "OutlineNodes",
|
||||
column: "ParentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OutlineNodes_ProjectId_ParentId_SortOrder",
|
||||
table: "OutlineNodes",
|
||||
columns: new[] { "ProjectId", "ParentId", "SortOrder" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Novelly.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(NovelDbContext))]
|
||||
partial class NovelDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("BeatTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("ChaptersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ChaptersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CharactersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("CharacterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SceneId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("WhatHappened")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WhatsNext")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("SceneId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Beats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RelationshipType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RelatedCharacterId");
|
||||
|
||||
b.ToTable("CharacterRelationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Conflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BeatsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChaptersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CharactersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Conversations")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ConversationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Conversation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany("Beats")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany()
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Scenes.Scene", "Scene")
|
||||
.WithMany()
|
||||
.HasForeignKey("SceneId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("Scene");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Characters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("RelatedCharacterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("RelatedCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany("Scenes")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Navigation("Beats");
|
||||
|
||||
b.Navigation("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
b.Navigation("Characters");
|
||||
|
||||
b.Navigation("Conversations");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Agent;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Stores a <see cref="DateTimeOffset"/> as UTC ticks. SQLite has no native type for it
|
||||
/// and refuses to ORDER BY the default text form, which every "most recently updated
|
||||
/// first" listing depends on. The domain only ever writes UtcNow, so normalising to UTC
|
||||
/// loses nothing.
|
||||
/// </summary>
|
||||
internal class UtcTicksConverter()
|
||||
: ValueConverter<DateTimeOffset, long>(
|
||||
value => value.UtcTicks,
|
||||
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
||||
|
||||
public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
: DbContext(options), INovelDbContext
|
||||
{
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
|
||||
public DbSet<Beat> Beats => Set<Beat>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
public DbSet<Scene> Scenes => Set<Scene>();
|
||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
||||
|
||||
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
|
||||
base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
protected override void ConfigureConventions(ModelConfigurationBuilder builder) =>
|
||||
builder.Properties<DateTimeOffset>().HaveConversion<UtcTicksConverter>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<Project>(entity =>
|
||||
{
|
||||
entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
|
||||
entity.HasMany(p => p.Characters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Tags).WithOne(t => t.Project!)
|
||||
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<Character>(entity =>
|
||||
{
|
||||
entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => c.ProjectId);
|
||||
|
||||
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CharacterRelationship>(entity =>
|
||||
{
|
||||
entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120);
|
||||
|
||||
// Restrict on the inverse side: deleting a character should not silently take
|
||||
// the other character's relationship rows with it via a second cascade path,
|
||||
// which SQLite rejects as a multiple-cascade cycle.
|
||||
entity.HasOne(r => r.RelatedCharacter).WithMany()
|
||||
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<Beat>(entity =>
|
||||
{
|
||||
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
|
||||
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
|
||||
|
||||
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
|
||||
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// A beat outlives the scene it was grouped under: deleting a scene is a
|
||||
// decision about prose, not about the plan.
|
||||
entity.HasOne(b => b.Scene).WithMany()
|
||||
.HasForeignKey(b => b.SceneId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(b => b.Character).WithMany()
|
||||
.HasForeignKey(b => b.CharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<Tag>(entity =>
|
||||
{
|
||||
entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
|
||||
entity.Property(t => t.Color).HasMaxLength(16);
|
||||
|
||||
// One canonical tag per name per project, so "betrayal" always means the
|
||||
// same tag no matter where it was typed.
|
||||
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
|
||||
|
||||
entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
|
||||
.UsingEntity(join => join.ToTable("CharacterTags"));
|
||||
entity.HasMany(t => t.Chapters).WithMany(c => c.Tags)
|
||||
.UsingEntity(join => join.ToTable("ChapterTags"));
|
||||
entity.HasMany(t => t.Beats).WithMany(b => b.Tags)
|
||||
.UsingEntity(join => join.ToTable("BeatTags"));
|
||||
});
|
||||
|
||||
builder.Entity<Chapter>(entity =>
|
||||
{
|
||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => new { c.ProjectId, c.Number });
|
||||
|
||||
entity.HasOne(c => c.PovCharacter).WithMany()
|
||||
.HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasMany(c => c.Scenes).WithOne(s => s.Chapter!)
|
||||
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<Scene>(entity =>
|
||||
{
|
||||
entity.Property(s => s.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(s => s.Status).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(s => new { s.ChapterId, s.SortOrder });
|
||||
|
||||
entity.HasOne(s => s.PovCharacter).WithMany()
|
||||
.HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<AgentConversation>(entity =>
|
||||
{
|
||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(200);
|
||||
entity.HasMany(c => c.Messages).WithOne(m => m.Conversation!)
|
||||
.HasForeignKey(m => m.ConversationId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AgentMessage>(entity =>
|
||||
{
|
||||
entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16);
|
||||
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Novelly.ServiceDefaults\Novelly.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Anthropic" Version="12.39.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Agent;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.Services.AddNovelly(builder.Configuration);
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Enums travel as their names, so the React client and the MCP server both read
|
||||
// "Protagonist" rather than an ordinal that shifts whenever the enum is reordered.
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
|
||||
?? ["http://localhost:5173"];
|
||||
|
||||
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
|
||||
.WithOrigins(corsOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Local-first tool: bring the SQLite file up to date on boot rather than making the
|
||||
// writer run a migration command before they can open the app.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
|
||||
}
|
||||
|
||||
app.UseExceptionHandler(handler => handler.Run(async context =>
|
||||
{
|
||||
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
|
||||
|
||||
var (status, title) = exception switch
|
||||
{
|
||||
NotFoundException => (StatusCodes.Status404NotFound, "Not found"),
|
||||
AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"),
|
||||
ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"),
|
||||
_ => (StatusCodes.Status500InternalServerError, "Unexpected error")
|
||||
};
|
||||
|
||||
if (status == StatusCodes.Status500InternalServerError)
|
||||
{
|
||||
app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
|
||||
}
|
||||
|
||||
await Results
|
||||
.Problem(title: title, detail: exception?.Message, statusCode: status)
|
||||
.ExecuteAsync(context);
|
||||
}));
|
||||
|
||||
app.UseCors();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health");
|
||||
|
||||
app.MapProjectEndpoints()
|
||||
.MapCharacterEndpoints()
|
||||
.MapChapterEndpoints()
|
||||
.MapBeatEndpoints()
|
||||
.MapSceneEndpoints()
|
||||
.MapTagEndpoints()
|
||||
.MapAgentEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,35 @@
|
||||
using Novelly.Api.Agent;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
/// <summary>A single novel and everything that belongs to it.</summary>
|
||||
public class Project
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Author { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
|
||||
/// <summary>One-sentence pitch.</summary>
|
||||
public string? Logline { get; set; }
|
||||
|
||||
/// <summary>Paragraph-length summary of the whole book.</summary>
|
||||
public string? Synopsis { get; set; }
|
||||
|
||||
/// <summary>Free-form notes on theme, tone, comparable titles, etc.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int? TargetWordCount { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public List<Character> Characters { get; set; } = [];
|
||||
public List<Chapter> Chapters { get; set; } = [];
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
public List<AgentConversation> Conversations { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
public record ProjectSummaryDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
string? Genre,
|
||||
string? Logline,
|
||||
int? TargetWordCount,
|
||||
int CharacterCount,
|
||||
int ChapterCount,
|
||||
int WordCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record ProjectDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
string? Genre,
|
||||
string? Logline,
|
||||
string? Synopsis,
|
||||
string? Notes,
|
||||
int? TargetWordCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateProjectRequest(
|
||||
string Title,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
string? Logline = null,
|
||||
string? Synopsis = null,
|
||||
string? Notes = null,
|
||||
int? TargetWordCount = null);
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update: every field is optional and null means "leave alone".
|
||||
/// Clearing a field is done by sending an empty string.
|
||||
/// </summary>
|
||||
public record UpdateProjectRequest(
|
||||
string? Title = null,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
string? Logline = null,
|
||||
string? Synopsis = null,
|
||||
string? Notes = null,
|
||||
int? TargetWordCount = null);
|
||||
|
||||
public static class ProjectMapping
|
||||
{
|
||||
public static ProjectDto ToDto(this Project p) => new(
|
||||
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
|
||||
p.TargetWordCount, p.CreatedAt, p.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
public static class ProjectEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/projects").WithTags("Projects");
|
||||
|
||||
group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(ct)))
|
||||
.WithSummary("List all novel projects.");
|
||||
|
||||
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
.WithSummary("Read a project's brief.");
|
||||
|
||||
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(request, ct);
|
||||
return Results.Created($"/api/projects/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Create a novel project.");
|
||||
|
||||
group.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Update a project's brief.");
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a project and everything in it.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
public class ProjectService(INovelDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default) =>
|
||||
await db.Projects
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.Select(p => new ProjectSummaryDto(
|
||||
p.Id,
|
||||
p.Title,
|
||||
p.Author,
|
||||
p.Genre,
|
||||
p.Logline,
|
||||
p.TargetWordCount,
|
||||
p.Characters.Count,
|
||||
p.Chapters.Count,
|
||||
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
|
||||
p.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var project = new Project
|
||||
{
|
||||
Title = request.Title,
|
||||
Author = request.Author,
|
||||
Genre = request.Genre,
|
||||
Logline = request.Logline,
|
||||
Synopsis = request.Synopsis,
|
||||
Notes = request.Notes,
|
||||
TargetWordCount = request.TargetWordCount
|
||||
};
|
||||
|
||||
db.Projects.Add(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return project.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var project = await FindAsync(id, ct);
|
||||
|
||||
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
|
||||
project.Author = Patch.Apply(project.Author, request.Author);
|
||||
project.Genre = Patch.Apply(project.Genre, request.Genre);
|
||||
project.Logline = Patch.Apply(project.Logline, request.Logline);
|
||||
project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
|
||||
project.Notes = Patch.Apply(project.Notes, request.Notes);
|
||||
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
|
||||
project.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return project.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var project = await FindAsync(id, ct);
|
||||
db.Projects.Remove(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<Project> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Project), id);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5266",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7123;http://localhost:5266",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent
|
||||
/// works with when turning an outline into prose.
|
||||
/// </summary>
|
||||
public class Scene
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>Position within the chapter, 1-based.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>What the POV character is trying to achieve.</summary>
|
||||
public string? Goal { get; set; }
|
||||
|
||||
/// <summary>What stands in the way.</summary>
|
||||
public string? Conflict { get; set; }
|
||||
|
||||
/// <summary>How it lands — and what it costs.</summary>
|
||||
public string? Outcome { get; set; }
|
||||
|
||||
public Guid? PovCharacterId { get; set; }
|
||||
public Character? PovCharacter { get; set; }
|
||||
|
||||
public string? Location { get; set; }
|
||||
|
||||
/// <summary>The drafted prose, if any.</summary>
|
||||
public string? Prose { get; set; }
|
||||
|
||||
public int WordCount { get; set; }
|
||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Novelly.Api.Common;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
public record SceneDto(
|
||||
Guid Id,
|
||||
Guid ChapterId,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? Summary,
|
||||
string? Goal,
|
||||
string? Conflict,
|
||||
string? Outcome,
|
||||
Guid? PovCharacterId,
|
||||
string? PovCharacterName,
|
||||
string? Location,
|
||||
string? Prose,
|
||||
int WordCount,
|
||||
DraftStatus Status,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateSceneRequest(
|
||||
string Title,
|
||||
int? SortOrder = null,
|
||||
string? Summary = null,
|
||||
string? Goal = null,
|
||||
string? Conflict = null,
|
||||
string? Outcome = null,
|
||||
Guid? PovCharacterId = null,
|
||||
string? Location = null,
|
||||
string? Prose = null,
|
||||
DraftStatus Status = DraftStatus.Planned);
|
||||
|
||||
public record UpdateSceneRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
string? Summary = null,
|
||||
string? Goal = null,
|
||||
string? Conflict = null,
|
||||
string? Outcome = null,
|
||||
Guid? PovCharacterId = null,
|
||||
string? Location = null,
|
||||
string? Prose = null,
|
||||
DraftStatus? Status = null);
|
||||
|
||||
public static class SceneMapping
|
||||
{
|
||||
public static SceneDto ToDto(this Scene s) => new(
|
||||
s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary,
|
||||
s.Goal, s.Conflict, s.Outcome,
|
||||
s.PovCharacterId, s.PovCharacter?.Name, s.Location,
|
||||
s.Prose, s.WordCount, s.Status, s.UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Whitespace-delimited word count. Good enough for progress tracking, and it costs
|
||||
/// nothing to recompute on every save.
|
||||
/// </summary>
|
||||
public static int CountWords(string? prose) =>
|
||||
string.IsNullOrWhiteSpace(prose)
|
||||
? 0
|
||||
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
public static class SceneEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes");
|
||||
|
||||
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(chapterId, ct)))
|
||||
.WithSummary("List a chapter's scenes in order.");
|
||||
|
||||
chapterScoped.MapPost("/", async (
|
||||
Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(chapterId, request, ct);
|
||||
return Results.Created($"/api/scenes/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Add a scene to a chapter.");
|
||||
|
||||
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes");
|
||||
|
||||
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
.WithSummary("Read a scene, including its prose.");
|
||||
|
||||
scenes.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Update a scene. Sending prose recomputes the word count.");
|
||||
|
||||
scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a scene.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
public class SceneService(INovelDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
var scenes = await Query()
|
||||
.Where(s => s.ChapterId == chapterId)
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. scenes.Select(s => s.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Chapter), chapterId);
|
||||
}
|
||||
|
||||
var scene = new Scene
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
||||
Summary = request.Summary,
|
||||
Goal = request.Goal,
|
||||
Conflict = request.Conflict,
|
||||
Outcome = request.Outcome,
|
||||
PovCharacterId = request.PovCharacterId,
|
||||
Location = request.Location,
|
||||
Prose = request.Prose,
|
||||
WordCount = SceneMapping.CountWords(request.Prose),
|
||||
Status = request.Status
|
||||
};
|
||||
|
||||
db.Scenes.Add(scene);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(scene.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var scene = await FindAsync(id, ct);
|
||||
|
||||
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
|
||||
scene.SortOrder = request.SortOrder ?? scene.SortOrder;
|
||||
scene.Summary = Patch.Apply(scene.Summary, request.Summary);
|
||||
scene.Goal = Patch.Apply(scene.Goal, request.Goal);
|
||||
scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict);
|
||||
scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome);
|
||||
scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId;
|
||||
scene.Location = Patch.Apply(scene.Location, request.Location);
|
||||
scene.Status = request.Status ?? scene.Status;
|
||||
|
||||
if (request.Prose is not null)
|
||||
{
|
||||
scene.Prose = Patch.Apply(scene.Prose, request.Prose);
|
||||
scene.WordCount = SceneMapping.CountWords(scene.Prose);
|
||||
}
|
||||
|
||||
scene.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var scene = await FindAsync(id, ct);
|
||||
db.Scenes.Remove(scene);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
{
|
||||
var max = await db.Scenes
|
||||
.Where(s => s.ChapterId == chapterId)
|
||||
.MaxAsync(s => (int?)s.SortOrder, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
|
||||
|
||||
private async Task<Scene> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Scene), id);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
/// <summary>
|
||||
/// A free-form label scoped to one project. Tags are the cross-reference mechanism:
|
||||
/// attach the same tag to a character, a chapter and a beat, then ask what else carries it.
|
||||
/// Names are unique within a project so "betrayal" always means the same tag.
|
||||
/// </summary>
|
||||
public class Tag
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional hex colour for the UI, e.g. "#9a4a2f".</summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
public List<Character> Characters { get; set; } = [];
|
||||
public List<Chapter> Chapters { get; set; } = [];
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
public record TagDto(Guid Id, string Name, string? Color);
|
||||
|
||||
public record TagSummaryDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Color,
|
||||
int CharacterCount,
|
||||
int ChapterCount,
|
||||
int BeatCount)
|
||||
{
|
||||
public int TotalCount => CharacterCount + ChapterCount + BeatCount;
|
||||
}
|
||||
|
||||
public record CreateTagRequest(string Name, string? Color = null);
|
||||
|
||||
public record UpdateTagRequest(string? Name = null, string? Color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Everything carrying one tag, gathered in a single response. This is the whole point of
|
||||
/// tags — seeing that a motif touches two characters, a chapter and four beats is what
|
||||
/// makes them worth maintaining.
|
||||
/// </summary>
|
||||
public record TagReferencesDto(
|
||||
TagDto Tag,
|
||||
IReadOnlyList<TaggedCharacterDto> Characters,
|
||||
IReadOnlyList<TaggedChapterDto> Chapters,
|
||||
IReadOnlyList<TaggedBeatDto> Beats);
|
||||
|
||||
public record TaggedCharacterDto(Guid Id, string Name, string Role);
|
||||
|
||||
public record TaggedChapterDto(Guid Id, int Number, string Title, string? Summary);
|
||||
|
||||
public record TaggedBeatDto(
|
||||
Guid Id,
|
||||
Guid ChapterId,
|
||||
int ChapterNumber,
|
||||
string ChapterTitle,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? CharacterName,
|
||||
string? WhatHappened);
|
||||
|
||||
public static class TagMapping
|
||||
{
|
||||
public static TagDto ToDto(this Tag t) => new(t.Id, t.Name, t.Color);
|
||||
|
||||
/// <summary>
|
||||
/// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and
|
||||
/// "betrayal" resolve to one tag rather than quietly becoming two.
|
||||
/// </summary>
|
||||
public static string Normalise(string name) => name.Trim();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
public static class TagEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags");
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
.WithSummary("List a project's tags with usage counts.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(projectId, request, ct);
|
||||
return Results.Created($"/api/tags/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Create a tag. Tags are also created on demand when applied by name.");
|
||||
|
||||
var tags = app.MapGroup("/api/tags").WithTags("Tags");
|
||||
|
||||
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetReferencesAsync(id, ct)))
|
||||
.WithSummary("Cross-reference: every character, chapter and beat carrying this tag.");
|
||||
|
||||
tags.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateTagRequest request, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Rename or recolour a tag.");
|
||||
|
||||
tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a tag. Whatever carried it is left alone.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
public class TagService(INovelDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) =>
|
||||
await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(t => new TagSummaryDto(
|
||||
t.Id, t.Name, t.Color,
|
||||
t.Characters.Count, t.Chapters.Count, t.Beats.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
/// <summary>Everything in the project carrying this tag.</summary>
|
||||
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await db.Tags
|
||||
.Include(t => t.Characters)
|
||||
.Include(t => t.Chapters)
|
||||
.Include(t => t.Beats).ThenInclude(b => b.Character)
|
||||
.Include(t => t.Beats).ThenInclude(b => b.Chapter)
|
||||
.FirstOrDefaultAsync(t => t.Id == tagId, ct)
|
||||
?? throw new NotFoundException(nameof(Tag), tagId);
|
||||
|
||||
return new TagReferencesDto(
|
||||
tag.ToDto(),
|
||||
[.. tag.Characters
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new TaggedCharacterDto(c.Id, c.Name, c.Role.ToString()))],
|
||||
[.. tag.Chapters
|
||||
.OrderBy(c => c.Number)
|
||||
.Select(c => new TaggedChapterDto(c.Id, c.Number, c.Title, c.Summary))],
|
||||
[.. tag.Beats
|
||||
.OrderBy(b => b.Chapter?.Number ?? 0)
|
||||
.ThenBy(b => b.SortOrder)
|
||||
.Select(b => new TaggedBeatDto(
|
||||
b.Id,
|
||||
b.ChapterId,
|
||||
b.Chapter?.Number ?? 0,
|
||||
b.Chapter?.Title ?? "(unknown chapter)",
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.Character?.Name,
|
||||
b.WhatHappened))]);
|
||||
}
|
||||
|
||||
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
var name = TagMapping.Normalise(request.Name);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("A tag needs a name.");
|
||||
}
|
||||
|
||||
var existing = await FindByNameAsync(projectId, name, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
|
||||
}
|
||||
|
||||
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color };
|
||||
db.Tags.Add(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag.ToDto();
|
||||
}
|
||||
|
||||
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
|
||||
?? throw new NotFoundException(nameof(Tag), tagId);
|
||||
|
||||
if (request.Name is not null)
|
||||
{
|
||||
var name = TagMapping.Normalise(request.Name);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("A tag needs a name.");
|
||||
}
|
||||
|
||||
var clash = await FindByNameAsync(tag.ProjectId, name, ct);
|
||||
if (clash is not null && clash.Id != tag.Id)
|
||||
{
|
||||
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'.");
|
||||
}
|
||||
|
||||
tag.Name = name;
|
||||
}
|
||||
|
||||
tag.Color = Patch.Apply(tag.Color, request.Color);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary>
|
||||
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
|
||||
?? throw new NotFoundException(nameof(Tag), tagId);
|
||||
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a list of names into tag entities, creating any the project has not seen
|
||||
/// before. Typing a new tag on a beat should just work rather than being a two-step
|
||||
/// "create the tag, then apply it".
|
||||
/// </summary>
|
||||
internal async Task<List<Tag>> ResolveAsync(
|
||||
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
||||
{
|
||||
var wanted = names
|
||||
.Select(TagMapping.Normalise)
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.DistinctBy(n => n.ToLowerInvariant())
|
||||
.ToList();
|
||||
|
||||
if (wanted.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var existing = await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var resolved = new List<Tag>();
|
||||
foreach (var name in wanted)
|
||||
{
|
||||
var match = existing.FirstOrDefault(
|
||||
t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
match = new Tag { ProjectId = projectId, Name = name };
|
||||
db.Tags.Add(match);
|
||||
existing.Add(match);
|
||||
}
|
||||
|
||||
resolved.Add(match);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task<Tag?> FindByNameAsync(Guid projectId, string name, CancellationToken ct) =>
|
||||
await db.Tags.FirstOrDefaultAsync(
|
||||
t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Novelly": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Novel": "Data Source=novel.db"
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [ "http://localhost:5173" ]
|
||||
},
|
||||
"Agent": {
|
||||
"Model": "claude-opus-5",
|
||||
"MaxTokens": 16000,
|
||||
"Effort": "high",
|
||||
"MaxIterations": 12
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user