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" }; } /// /// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc. /// public async Task 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 = WorkspaceHelper.TempDir; 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(); var toolNames = toolDefs.Length > 0 ? JsonSerializer.Serialize(toolDefs.Select(td => { var json = JsonSerializer.Serialize(td); var el = JsonSerializer.Deserialize(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(); 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 }; } /// /// 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. /// public async Task 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; } /// /// 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. /// /// /// 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). /// public async Task> BuildHistoryFromDb(string sessionId, string includeTag = null, List promptBlocks = null) { var chainsList = (await _db.GetActiveChains(sessionId)).ToList(); // chronologisch sortieren (älteste zuerst) chainsList.Reverse(); var history = new List(); 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 { ["role"] = "user", ["content"] = new List { new { type = "text", text = msg.Content } } }); } else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content)) { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "text", text = msg.Content } } }); } } continue; } // Letzte Chain: vollständig mit Tool-Calls var grouped = new Dictionary>(); foreach (var msg in msgs) { if (!grouped.ContainsKey(msg.Iteration)) grouped[msg.Iteration] = new List(); 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(); // 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 { ["role"] = "user", ["content"] = content }); } else if (msg.Role == "assistant" && msg.Type == "text") { if (!string.IsNullOrEmpty(msg.Content)) { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "text", text = msg.Content } } }); } } else if (msg.Role == "assistant" && msg.Type == "tool_use") { JsonElement inputEl; try { inputEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { inputEl = JsonSerializer.Deserialize("{}"); } var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }); } else { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl } } }); } } else if (msg.Role == "assistant" && msg.Type == "server_tool_use") { // Server-Tool-Use (web_search, code_execution etc.) – Raw-JSON in Assistant-Content JsonElement useEl; try { useEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { continue; } var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(useEl); } else { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { useEl } }); } } else if (msg.Role == "assistant" && msg.Type == "server_tool_result") { // Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.) // werden als Raw-JSON in den Assistant-Content-Block eingefügt JsonElement resultEl; try { resultEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { continue; } var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(resultEl); } else { history.Add(new Dictionary { ["role"] = "assistant", ["content"] = new List { resultEl } }); } } else if (msg.Role == "user" && msg.Type == "tool_result") { var lastUser = history.Count > 0 ? history[history.Count - 1] : null; if (lastUser is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "user" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = AnthropicService.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }); } else { history.Add(new Dictionary { ["role"] = "user", ["content"] = new List { new { type = "tool_result", tool_use_id = msg.ToolCallId, content = AnthropicService.TruncateToolResult(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 { ["role"] = "user", ["content"] = new List { new { type = "text", text = "[System Reminder] " + reminder } } }; var reminderAssistant = new Dictionary { ["role"] = "assistant", ["content"] = new List { 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 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 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; } /// /// Baut einen SubAgentContext für Preparer/Review/Finalizer. /// Gemeinsame Logik: Agent laden, Model auflösen, SystemPrompt bauen, ToolChoice bestimmen. /// private async Task 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(); 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 }; } }