Add users, roles, and per-novel permissions
Introduces accounts (ASP.NET Identity + cookie auth), four global roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants via ProjectMember, and a service-API-key principal for the MCP server and background import jobs. Enforcement lives in the application services (not endpoint filters) so the embedded agent and MCP tools, which call the same services directly, can't bypass it. Web client gets a login page, session-aware routing, and a People section for managing per-novel access. Also includes prior in-flight changes from this branch (CLAUDE.md compliance pass, dev-deploy docker-compose setup) that were uncommitted when this feature work started.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
@@ -20,7 +20,7 @@ public class NovelAgentService(
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
@@ -63,12 +63,11 @@ public class NovelAgentService(
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<AgentMessage?> SendMessageAsync(
|
||||
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
public async Task<AgentMessage?> SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(request, nameof(request));
|
||||
sendMessageValidator.Validate(request).ThrowIfInvalid();
|
||||
sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
|
||||
@@ -77,25 +76,15 @@ public class NovelAgentService(
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is null)
|
||||
{
|
||||
logger.LogInformation("Project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Project {ProjectId} not found", projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
AgentConversation conversation;
|
||||
if (request.ConversationId is { } id)
|
||||
{
|
||||
var found = await FindConversationAsync(id, ct);
|
||||
if (found is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var conversation = request.ConversationId is { } id
|
||||
? await FindConversationAsync(id, ct)
|
||||
: StartConversation(projectId, request.Message);
|
||||
|
||||
conversation = found;
|
||||
}
|
||||
else
|
||||
{
|
||||
conversation = StartConversation(projectId, request.Message);
|
||||
}
|
||||
if (conversation is null) return null;
|
||||
|
||||
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||
|
||||
@@ -113,16 +102,11 @@ public class NovelAgentService(
|
||||
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;
|
||||
}
|
||||
if (requestedTools.Count == 0) break;
|
||||
|
||||
transcript.Add(AgentChatMessage.Assistant(response.Content));
|
||||
|
||||
@@ -131,9 +115,7 @@ public class NovelAgentService(
|
||||
{
|
||||
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");
|
||||
logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
|
||||
|
||||
toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content));
|
||||
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
|
||||
@@ -141,15 +123,11 @@ public class NovelAgentService(
|
||||
|
||||
transcript.Add(AgentChatMessage.User([.. results]));
|
||||
|
||||
if (iteration == _options.MaxIterations - 1)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Agent hit the {Max}-iteration ceiling on project {ProjectId}",
|
||||
_options.MaxIterations, projectId);
|
||||
if (iteration != _options.MaxIterations - 1) continue;
|
||||
|
||||
text.AppendLine(
|
||||
"_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
|
||||
}
|
||||
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(
|
||||
@@ -162,8 +140,7 @@ public class NovelAgentService(
|
||||
return reply;
|
||||
}
|
||||
|
||||
private async Task<AgentMessage> AppendMessageAsync(
|
||||
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
||||
private async Task<AgentMessage> AppendMessageAsync(AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Appending {Role} message to conversation {ConversationId}, content length {ContentLength}", role, conversation.Id, content.Length);
|
||||
|
||||
@@ -182,10 +159,9 @@ public class NovelAgentService(
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (!conversation.Messages.Contains(message))
|
||||
{
|
||||
conversation.Messages.Add(message);
|
||||
}
|
||||
|
||||
logger.LogDebug("Appended {Role} message {MessageId} to conversation {ConversationId}", role, message.Id, conversation.Id);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -200,6 +176,8 @@ public class NovelAgentService(
|
||||
};
|
||||
|
||||
db.Conversations.Add(conversation);
|
||||
|
||||
logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
@@ -213,9 +191,11 @@ public class NovelAgentService(
|
||||
|
||||
if (conversation is null)
|
||||
{
|
||||
logger.LogInformation("AgentConversation {ConversationId} not found", conversationId);
|
||||
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
logger.LogDebug("Found agent conversation {ConversationId}", conversationId);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user