- Neues optionales Session-Feld 'preparerAgentId' (DB + Model + API) - PreparerContext im ChatContext für Agent-Konfiguration - RunPreparer() in AnthropicService: führt Preparer vor dem Worker aus - [FORWARD] → aufbereiteter Prompt wird an Session-Agent weitergegeben - Ohne [FORWARD] → Rückfrage an User, Flow stoppt (preparer_query) - Preparer-System-Prompt: erklärt Rolle, [FORWARD]-Mechanismus, Vorarbeit - Preparer kann eigene Tools + Fähigkeiten aktiv nutzen - ChatEndpoint: Preparer-Phase vor StreamChat integriert - Frontend: SSE-Events (preparer_start/text/tool_use/done) - Preparer-Bubble (Info-Farbe) mit Status Weitergeleitet/Rückfrage - [Preparer: Name] → hübscher Header, [FORWARD] ausgeblendet - [Vorbereitet von Name] → dezenter Header in User-Bubble - Agent-Picker im Session-Form (vor Review-Agent)
215 lines
8.6 KiB
C#
215 lines
8.6 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Gott.Extensions;
|
|
using Gott.Models;
|
|
using Gott.Services;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace Gott.Endpoints;
|
|
|
|
public static class ChatEndpoint
|
|
{
|
|
public static void MapChatEndpoint(this IEndpointRouteBuilder app)
|
|
{
|
|
app.MapPost("/api/chat", HandleChat);
|
|
app.MapPost("/api/chat/stop", HandleStop);
|
|
}
|
|
|
|
private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, CancellationToken cancellationToken)
|
|
{
|
|
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
|
|
if (body == null)
|
|
{
|
|
res.StatusCode = 400;
|
|
await res.WriteAsJsonAsync(new { error = "body required" });
|
|
return;
|
|
}
|
|
|
|
var sessionId = body.SessionId;
|
|
if (string.IsNullOrEmpty(sessionId))
|
|
{
|
|
res.StatusCode = 400;
|
|
await res.WriteAsJsonAsync(new { error = "sessionId required" });
|
|
return;
|
|
}
|
|
|
|
// Prüfe ob dies eine Choice-Antwort ist (ask_user_choice response)
|
|
var isChoiceResponse = !string.IsNullOrEmpty(body.ChoiceToolCallId) && !string.IsNullOrEmpty(body.ChoiceResult);
|
|
|
|
if (!isChoiceResponse && body.Content.ValueKind == JsonValueKind.Undefined)
|
|
{
|
|
res.StatusCode = 400;
|
|
await res.WriteAsJsonAsync(new { error = "content required" });
|
|
return;
|
|
}
|
|
|
|
var userId = req.HttpContext.GetUserId();
|
|
var maxIterations = config.GetValue<int>("MaxIterations", 100);
|
|
|
|
// Bei Choice-Response: planMode vom vorherigen Chain übernehmen
|
|
var effectivePlanMode = body.PlanMode;
|
|
if (isChoiceResponse && !effectivePlanMode)
|
|
{
|
|
var prevChainId = await chatService.FindChainWithToolCall(sessionId, body.ChoiceToolCallId);
|
|
if (prevChainId != null)
|
|
{
|
|
var prevChain = await db.GetChain(prevChainId);
|
|
if (prevChain != null) effectivePlanMode = prevChain.PlanMode;
|
|
}
|
|
}
|
|
|
|
var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations, planMode: effectivePlanMode);
|
|
if (ctx == null)
|
|
{
|
|
res.StatusCode = 400;
|
|
await res.WriteAsJsonAsync(new { error = "Session/Agent nicht gefunden oder kein Agent ausgewählt" });
|
|
return;
|
|
}
|
|
|
|
if (isChoiceResponse)
|
|
{
|
|
// Choice-Antwort: tool_result in die VORHERIGE Chain speichern (wo der tool_use liegt)
|
|
// Damit BuildHistoryFromDb die tool_use + tool_result korrekt zusammenbauen kann.
|
|
var previousChainId = await chatService.FindChainWithToolCall(sessionId, body.ChoiceToolCallId);
|
|
if (previousChainId != null)
|
|
{
|
|
await db.SaveMessage(new MessageRecord
|
|
{
|
|
ChainId = previousChainId,
|
|
Iteration = 999, // Hohe Iteration damit es nach dem tool_use kommt
|
|
Role = "user",
|
|
Type = "tool_result",
|
|
ToolCallId = body.ChoiceToolCallId,
|
|
Content = body.ChoiceResult
|
|
});
|
|
}
|
|
else
|
|
{
|
|
// Fallback: In aktuelle Chain speichern
|
|
await db.SaveMessage(new MessageRecord
|
|
{
|
|
ChainId = ctx.ChainId,
|
|
Iteration = 0,
|
|
Role = "user",
|
|
Type = "tool_result",
|
|
ToolCallId = body.ChoiceToolCallId,
|
|
Content = body.ChoiceResult
|
|
});
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Normale User-Message aufbereiten und speichern
|
|
var userMessage = new ChatMessage { Role = "user", Content = body.Content };
|
|
var textContent = userMessage.GetTextContent();
|
|
var attachmentBlocks = userMessage.GetAttachmentBlocks();
|
|
|
|
var attachmentRecords = attachmentBlocks.Select(b => new AttachmentRecord
|
|
{
|
|
Type = b.Type,
|
|
MediaType = b.MediaType,
|
|
FileName = b.FileName,
|
|
Data = b.Data
|
|
}).ToList();
|
|
|
|
await db.SaveMessageWithAttachments(new MessageRecord
|
|
{
|
|
ChainId = ctx.ChainId,
|
|
Iteration = 0,
|
|
Role = "user",
|
|
Type = "text",
|
|
Content = textContent
|
|
}, attachmentRecords);
|
|
}
|
|
|
|
// History komplett aus DB aufbauen (inkl. gerade gespeicherter Message/tool_result)
|
|
var messages = await chatService.BuildHistoryFromDb(sessionId);
|
|
|
|
res.Headers["Content-Type"] = "text/event-stream";
|
|
res.Headers["Cache-Control"] = "no-cache";
|
|
res.Headers["Connection"] = "keep-alive";
|
|
|
|
// ── Preparer-Agent Phase (vor dem Worker) ──
|
|
if (ctx.Preparer != null && !isChoiceResponse)
|
|
{
|
|
var preparerResult = await anthropic.RunPreparer(
|
|
res, ctx.Preparer, messages, ctx.ChainId, ctx.Workspace, sessionId, cancellationToken);
|
|
|
|
if (!preparerResult.ShouldForward)
|
|
{
|
|
// Preparer hat eine Rückfrage → Antwort als Text streamen, Flow beenden
|
|
// Preparer-Antwort wird schon in RunAgentChat in DB gespeichert
|
|
await AnthropicService.WriteSseEvent(res, "done", new
|
|
{
|
|
chainId = ctx.ChainId,
|
|
stopReason = "preparer_query",
|
|
planMode = ctx.PlanMode
|
|
}, cancellationToken);
|
|
return;
|
|
}
|
|
|
|
// Preparer ist zufrieden → aufbereiteten Prompt als zusätzlichen Kontext an die History anhängen
|
|
if (!string.IsNullOrWhiteSpace(preparerResult.ForwardedPrompt))
|
|
{
|
|
// Den aufbereiteten Prompt als System-Context-Message einfügen
|
|
messages.Add(new Dictionary<string, object>
|
|
{
|
|
["role"] = "user",
|
|
["content"] = new List<object>
|
|
{
|
|
new { type = "text", text = $"[Vorbereitet von {ctx.Preparer.Agent?.Name ?? "Vorbereiter-Agent"}]:\n{preparerResult.ForwardedPrompt}" }
|
|
}
|
|
});
|
|
|
|
// Auch in DB speichern
|
|
await db.SaveMessage(new MessageRecord
|
|
{
|
|
ChainId = ctx.ChainId,
|
|
Iteration = 0,
|
|
Role = "user",
|
|
Type = "text",
|
|
Content = $"[Vorbereitet von {ctx.Preparer.Agent?.Name ?? "Vorbereiter-Agent"}]:\n{preparerResult.ForwardedPrompt}"
|
|
});
|
|
}
|
|
}
|
|
|
|
// Bei Choice-Response: toolChoice auf "auto" setzen, damit Claude frei antworten kann
|
|
var effectiveToolChoice = isChoiceResponse ? "auto" : ctx.ToolChoice;
|
|
|
|
// CancellationToken: Verknüpft Stop-Button (über StreamingTracker) + Client-Disconnect
|
|
var stopCts = streamingTracker.Start(sessionId, ctx.ChainId);
|
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopCts.Token);
|
|
|
|
try
|
|
{
|
|
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, cancellationToken: linkedCts.Token);
|
|
}
|
|
finally
|
|
{
|
|
streamingTracker.Stop(sessionId);
|
|
}
|
|
}
|
|
|
|
private static async Task HandleStop(HttpRequest req, HttpResponse res, StreamingTracker streamingTracker)
|
|
{
|
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(req.Body);
|
|
var sessionId = body.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
|
|
|
|
if (string.IsNullOrEmpty(sessionId))
|
|
{
|
|
res.StatusCode = 400;
|
|
await res.WriteAsJsonAsync(new { error = "sessionId required" });
|
|
return;
|
|
}
|
|
|
|
var cancelled = streamingTracker.Cancel(sessionId);
|
|
await res.WriteAsJsonAsync(new { stopped = cancelled });
|
|
}
|
|
}
|