Files
gott/Services/ChatService.cs
T
Meleki dcedbaa074 refactor: Backend deduplizieren – 13 Dateien, -528 Zeilen netto
ChatContext: 3 identische Context-Klassen (PreparerContext, ReviewContext,
FinalizerContext) zu einer SubAgentContext-Klasse zusammengefasst.

ChatService: 3x40 Zeilen Sub-Agent-Setup durch BuildSubAgentContext() ersetzt.

AnthropicService: 5 Hilfsmethoden extrahiert – ExtractWebSearchSources(),
TruncateToolResult(), AddToolsToPayload(), ParseServerToolUse(), TrackUsage().

WorkspaceEndpoint: 5 Session/Agent-Endpoint-Paare durch generische Core-Handler
mit basePath-Parameter ersetzt. Ungenutzte rmdir-Endpoints entfernt.

AgentEndpoint: Doppeltes Agent-DTO-Mapping durch MapAgentList() ersetzt.

SessionEndpoint: Doppelte Session/Agent-Auflösung durch ResolvePromptContext() ersetzt.

DatabaseService: Toten Code entfernt (SetSessionTelegramChatId,
ClearSessionTelegramChatId, GetSessions, GetUserSessions). ConvertDataUrlToWebP
und ResizeDataUrlToWebP zu einer Methode zusammengefasst.

TelegramEndpoint: Bot-DTO-Mapping durch MapBotResponse() dedupliziert.

KreaEndpoint + SkillEndpoint: Thumbnail-Generierung in ImageService.GenerateThumbnail()
extrahiert, SixLabors-Imports aus Endpoints entfernt.

CronjobEndpoint: 7x ctx.Items[UserId] durch ctx.GetUserId() ersetzt.
2026-03-09 09:18:49 +01:00

482 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Gott.Models;
namespace Gott.Services;
public class ChatService
{
private readonly DatabaseService _db;
private readonly ToolService _toolService;
private readonly MemoryService _memoryService;
private readonly SkillService _skillService;
private readonly int _maxHistory;
private readonly int _reminderInterval;
public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService,
int maxHistory = 120, int reminderInterval = 30)
{
_db = db;
_toolService = toolService;
_memoryService = memoryService;
_skillService = skillService;
_maxHistory = maxHistory;
_reminderInterval = reminderInterval;
}
public static string ResolveModelId(string model)
{
return model switch
{
"sonnet" => "claude-sonnet-4-6",
"sonnet-4-5" => "claude-sonnet-4-5",
"sonnet-4-6" => "claude-sonnet-4-6",
"opus" => "claude-opus-4-6",
"opus-4-5" => "claude-opus-4-5",
"opus-4-6" => "claude-opus-4-6",
"haiku" => "claude-haiku-4-5",
"haiku-4-5" => "claude-haiku-4-5",
_ => "claude-sonnet-4-6"
};
}
/// <summary>
/// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc.
/// </summary>
public async Task<ChatContext> PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false)
{
var session = await _db.GetSession(sessionId);
if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId))
return null;
var agent = await _db.GetAgent(session.AgentId);
if (agent == null)
return null;
var modelId = ResolveModelId(agent.Model);
var markedMessages = await _db.GetMarkedMessages(sessionId);
var agentMemoryDir = _memoryService.GetAgentMemoryDir(agent.AgentId);
var agentSkills = await _db.GetAgentSkills(agent.AgentId);
var user = userId != null ? await _db.GetUser(userId) : null;
var workspace = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workspace) && agentSkills.Any())
workspace = "/tmp";
var promptBlocks = SystemPromptBuilder.BuildBlocks(
agent, session, markedMessages, _memoryService, agentMemoryDir,
agentSkills, _skillService, userPersona: user?.GetEffectivePersona(),
resolvedWorkspace: workspace, planMode: planMode);
var systemPromptText = string.Join("\n\n", promptBlocks.Select(b => b.Content));
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
var hasTools = hasWorkspace || includeMemory;
var toolChoice = hasTools ? "any" : null;
var toolDefs = hasTools
? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice)
: Array.Empty<object>();
var toolNames = toolDefs.Length > 0
? JsonSerializer.Serialize(toolDefs.Select(td =>
{
var json = JsonSerializer.Serialize(td);
var el = JsonSerializer.Deserialize<JsonElement>(json);
return el.GetProperty("name").GetString();
}).ToArray())
: null;
var chainId = Guid.NewGuid().ToString("N");
await _db.CreateChain(new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
AgentId = agent.AgentId,
Model = modelId,
SystemPrompt = systemPromptText,
ToolChoice = toolChoice,
ToolNames = toolNames,
PlanMode = planMode
});
await _db.UpdateSessionTimestamp(sessionId);
if (!string.IsNullOrEmpty(workspace))
Directory.CreateDirectory(workspace);
var systemPrompt = new List<object>();
if (!string.IsNullOrWhiteSpace(systemPromptText))
systemPrompt.Add(new { type = "text", text = systemPromptText });
// Sub-Agents vorbereiten (falls konfiguriert)
var preparerContext = await BuildSubAgentContext(
session.PreparerAgentId, sessionId, workspace, session, user);
var reviewContext = await BuildSubAgentContext(
session.ReviewAgentId, sessionId, workspace, session, user);
var finalizerContext = await BuildSubAgentContext(
session.FinalizerAgentId, sessionId, workspace, session, user);
if (preparerContext != null)
{
preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false;
preparerContext.SystemPrompt.Add(new { type = "text", text = @"## KRITISCH: Deine Rolle als Vorbereiter-Agent
Du bist ein Vorbereiter-Agent. Deine Aufgabe: Die Anfrage des Users aufbereiten, deine Fähigkeiten nutzen, und das Ergebnis an den Session-Agent weiterleiten.
### Standardverhalten: Automatische Weiterleitung
Deine Antwort wird AUTOMATISCH an den Session-Agent weitergeleitet. Du musst nichts Besonderes tun – antworte einfach mit dem aufbereiteten Ergebnis. Optional kannst du `[FORWARD]` an den Anfang schreiben, aber es ist nicht nötig.
### Ausnahme: Rückfrage an den User
NUR wenn du eine echte Rückfrage hast (Anfrage ist unklar/mehrdeutig), schreibe `[QUESTION]` an den Anfang deiner Antwort. Dann wird deine Antwort dem User als Rückfrage angezeigt und NICHT an den Session-Agent weitergeleitet.
### Nutze deine Fähigkeiten aktiv!
Du hast eigene Tools. Nutze sie! Erledige was du kannst (Dateien lesen, Daten aufbereiten, Strukturen erstellen, recherchieren). Der Session-Agent soll ein möglichst gut vorbereitetes Paket bekommen.
### Beispiele:
Weiterleitung (wird automatisch an den Session-Agent übergeben):
```
Der User möchte Produktdaten als JSON. Ich habe es bereits erstellt:
{""products"": [{""id"": 1, ""name"": ""Widget""}]}
Bitte speichere es in products.json.
```
Rückfrage (NUR mit [QUESTION] – stoppt die Weiterleitung):
```
[QUESTION]
Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
```" });
}
if (reviewContext != null)
reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3;
return new ChatContext
{
ChainId = chainId,
SessionId = sessionId,
ModelId = modelId,
Workspace = workspace,
AgentId = agent.AgentId,
SystemPromptText = systemPromptText,
SystemPrompt = systemPrompt,
PromptBlocks = promptBlocks,
ToolChoice = toolChoice,
UseMemory = agent.UseMemory,
WebSearchVersion = agent.WebSearchVersion,
MaxIterations = maxIterations,
Agent = agent,
Session = session,
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
PlanMode = planMode
};
}
/// <summary>
/// Findet die Chain-ID die einen bestimmten tool_use-Call enthält.
/// Wird genutzt um bei ask_user_choice das tool_result in die richtige Chain zu speichern.
/// </summary>
public async Task<string> FindChainWithToolCall(string sessionId, string toolCallId)
{
var chains = (await _db.GetActiveChains(sessionId)).ToList();
foreach (var chain in chains)
{
var messages = await _db.GetChainMessages(chain.ChainId);
foreach (var msg in messages)
{
if (msg.Role == "assistant" && msg.Type == "tool_use" && msg.ToolCallId == toolCallId)
return chain.ChainId;
}
}
return null;
}
/// <summary>
/// Baut die Message-History aus der DB (aktive Chains).
/// Optimiert: Nur Text-Messages (user/assistant) für ältere Chains,
/// Tool-Calls nur für die aktuelle Chain, Attachments nur für die letzte User-Message.
/// </summary>
/// <summary>
/// Baut die Message-History aus der DB (aktive Chains).
/// includeTag: Wenn gesetzt, werden Messages mit diesem Tag NICHT ausgefiltert
/// (z.B. "preparer" damit der Preparer seine eigenen vorherigen Messages sieht).
/// </summary>
public async Task<List<object>> BuildHistoryFromDb(string sessionId, string includeTag = null, List<PromptBlock> promptBlocks = null)
{
var chainsList = (await _db.GetActiveChains(sessionId)).ToList();
// chronologisch sortieren (älteste zuerst)
chainsList.Reverse();
var history = new List<object>();
var isLastChain = false;
long? lastUserMsgId = null;
// Bestimme welche Chain als "vollständige letzte Chain" gilt.
// Wenn die letzte Chain leer oder nur tool_result enthält (z.B. nach ask_user_choice),
// dann muss die vorletzte Chain vollständig rekonstruiert werden.
int fullChainStartIndex = chainsList.Count - 1;
if (chainsList.Count >= 2)
{
var lastChainMsgs = (await _db.GetChainMessages(chainsList[chainsList.Count - 1].ChainId)).ToList();
if (lastChainMsgs.Count == 0 ||
(lastChainMsgs.All(m => m.Type == "tool_result") && lastChainMsgs.Count <= 2))
{
// Vorletzte Chain hatte einen ask_user_choice → beide Chains vollständig laden
fullChainStartIndex = chainsList.Count - 2;
}
}
for (int i = 0; i < chainsList.Count; i++)
{
var chain = chainsList[i];
isLastChain = (i == chainsList.Count - 1);
var msgs = (await _db.GetChainMessages(chain.ChainId))
.Where(m => string.IsNullOrEmpty(m.Tag) || m.Tag == "openclaw-import" || m.Tag == includeTag) // Sub-Agent-Messages ausfiltern; openclaw-import ist Herkunfts-Tag und wird immer durchgelassen
.ToList();
// Letzte User-Message-ID der letzten Chain merken (für Attachments)
if (isLastChain || i == fullChainStartIndex)
{
var lastUserMsg = msgs.LastOrDefault(m => m.Role == "user" && m.Type == "text");
if (lastUserMsg != null)
lastUserMsgId = lastUserMsg.Id;
}
// Für ältere Chains: nur Text-Messages (wie Frontend es vorher gemacht hat)
if (i < fullChainStartIndex)
{
foreach (var msg in msgs)
{
if (msg.Role == "user" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
}
continue;
}
// Letzte Chain: vollständig mit Tool-Calls
var grouped = new Dictionary<int, List<MessageRecord>>();
foreach (var msg in msgs)
{
if (!grouped.ContainsKey(msg.Iteration))
grouped[msg.Iteration] = new List<MessageRecord>();
grouped[msg.Iteration].Add(msg);
}
foreach (var kvp in grouped.OrderBy(x => x.Key))
{
foreach (var msg in kvp.Value)
{
if (msg.Role == "user" && msg.Type == "text")
{
var content = new List<object>();
// Attachments nur für die letzte User-Message laden
if (msg.Id == lastUserMsgId)
{
var attachments = await _db.GetAttachments(msg.Id);
foreach (var att in attachments)
{
if (att.Type == "image")
{
content.Add(new
{
type = "image",
source = new { type = "base64", media_type = att.MediaType, data = att.Data }
});
}
else if (att.Type == "document")
{
content.Add(new
{
type = "document",
source = new { type = "base64", media_type = att.MediaType, data = att.Data }
});
}
}
}
if (!string.IsNullOrEmpty(msg.Content))
content.Add(new { type = "text", text = msg.Content });
if (content.Count > 0)
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = content });
}
else if (msg.Role == "assistant" && msg.Type == "text")
{
if (!string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
}
else if (msg.Role == "assistant" && msg.Type == "tool_use")
{
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
if (lastAssistant is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl });
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object>
{
new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }
}
});
}
}
else if (msg.Role == "user" && msg.Type == "tool_result")
{
var lastUser = history.Count > 0 ? history[history.Count - 1] : null;
if (lastUser is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "user" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = msg.Content ?? "", is_error = msg.IsError });
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object>
{
new { type = "tool_result", tool_use_id = msg.ToolCallId, content = msg.Content ?? "", is_error = msg.IsError }
}
});
}
}
}
}
}
// History auf maximal N Messages begrenzen (älteste entfernen)
if (history.Count > _maxHistory)
history.RemoveRange(0, history.Count - _maxHistory);
// Reminder einfügen: alle ~30 user/assistant-Paare ein Reminder-Paar einfügen
var reminder = SystemPromptBuilder.BuildReminder(promptBlocks);
if (!string.IsNullOrWhiteSpace(reminder) && _reminderInterval > 0 && history.Count > _reminderInterval)
{
var reminderUser = new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = "[System Reminder] " + reminder } }
};
var reminderAssistant = new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = "Verstanden." } }
};
// Alle 30 Messages ein Reminder-Paar einfügen (von hinten nach vorne, damit Indizes stimmen)
var insertPositions = new List<int>();
int messageCount = 0;
for (int i = 0; i < history.Count; i++)
{
messageCount++;
if (messageCount > 0 && messageCount % _reminderInterval == 0)
{
// Position nach dem aktuellen Element – aber nur einfügen wenn es eine
// assistant-Message ist (damit user/assistant-Alternierung nicht bricht)
if (history[i] is Dictionary<string, object> d &&
d.TryGetValue("role", out var r) && r.ToString() == "assistant")
{
insertPositions.Add(i + 1);
}
}
}
// Von hinten nach vorne einfügen, damit Indizes nicht verschoben werden
for (int j = insertPositions.Count - 1; j >= 0; j--)
{
var pos = insertPositions[j];
history.Insert(pos, reminderAssistant);
history.Insert(pos, reminderUser);
}
}
return history;
}
/// <summary>
/// Baut einen SubAgentContext für Preparer/Review/Finalizer.
/// Gemeinsame Logik: Agent laden, Model auflösen, SystemPrompt bauen, ToolChoice bestimmen.
/// </summary>
private async Task<SubAgentContext> BuildSubAgentContext(
string agentId, string sessionId, string workspace, SessionRecord session, UserRecord user)
{
if (string.IsNullOrEmpty(agentId))
return null;
var agent = await _db.GetAgent(agentId);
if (agent == null)
return null;
var modelId = ResolveModelId(agent.Model);
var markedMessages = await _db.GetMarkedMessages(sessionId);
var memDir = _memoryService.GetAgentMemoryDir(agent.AgentId);
var skills = await _db.GetAgentSkills(agent.AgentId);
var systemText = SystemPromptBuilder.Build(
agent, session, markedMessages, _memoryService, memDir,
skills, _skillService, userPersona: user?.GetEffectivePersona(),
resolvedWorkspace: workspace);
var prompt = new List<object>();
if (!string.IsNullOrWhiteSpace(systemText))
prompt.Add(new { type = "text", text = systemText });
var hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory;
return new SubAgentContext
{
ModelId = modelId,
SystemPrompt = prompt,
AgentId = agent.AgentId,
Workspace = workspace,
UseMemory = agent.UseMemory,
WebSearchVersion = agent.WebSearchVersion,
ToolChoice = hasTools ? "any" : "auto",
Agent = agent
};
}
}