- Toggle-Button (🗺️) im Chat-Input-Bereich neben Explorer - Plan-Modus wird per ChatRequest.planMode an Backend gesendet - Backend: Write-Tools gefiltert (nur read_file, list_files, glob, grep, todo_write, memory_*, ask_user_choice bleiben), System-Prompt-Block ergänzt der Änderungen verbietet, tool_choice nie 'any' erzwungen - ChainRecord.PlanMode: pro Chain gespeichert (informativ für History) - Chain-Meta-Badge '📋 Plan-Modus' bei Plan-Chains - Choice-Response erbt planMode vom vorherigen Chain - CSS: .plan-mode-active Styling (Primary-Color Highlight) - Placeholder wechselt zu 'Plannen...' wenn aktiv - Plan-Modus wird bei Session-Wechsel zurückgesetzt
403 lines
17 KiB
C#
403 lines
17 KiB
C#
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;
|
|
|
|
public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService)
|
|
{
|
|
_db = db;
|
|
_toolService = toolService;
|
|
_memoryService = memoryService;
|
|
_skillService = skillService;
|
|
}
|
|
|
|
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 systemPromptText = SystemPromptBuilder.Build(
|
|
agent, session, markedMessages, _memoryService, agentMemoryDir,
|
|
agentSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
|
resolvedWorkspace: workspace);
|
|
|
|
var toolChoice = agent.ToolChoice ?? "any";
|
|
var hasWorkspace = !string.IsNullOrEmpty(workspace);
|
|
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
|
|
|
|
var toolDefs = (hasWorkspace || includeMemory)
|
|
? _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 = (hasWorkspace || includeMemory) ? toolChoice : null,
|
|
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 });
|
|
|
|
// Review-Agent vorbereiten (falls konfiguriert)
|
|
ReviewContext reviewContext = null;
|
|
if (!string.IsNullOrEmpty(session.ReviewAgentId))
|
|
{
|
|
var reviewAgent = await _db.GetAgent(session.ReviewAgentId);
|
|
if (reviewAgent != null)
|
|
{
|
|
var reviewModelId = ResolveModelId(reviewAgent.Model);
|
|
var reviewMarked = await _db.GetMarkedMessages(sessionId);
|
|
var reviewMemDir = _memoryService.GetAgentMemoryDir(reviewAgent.AgentId);
|
|
var reviewSkills = await _db.GetAgentSkills(reviewAgent.AgentId);
|
|
var reviewWorkspace = workspace; // gleicher Workspace wie Worker
|
|
var reviewSystemText = SystemPromptBuilder.Build(
|
|
reviewAgent, session, reviewMarked, _memoryService, reviewMemDir,
|
|
reviewSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
|
resolvedWorkspace: reviewWorkspace);
|
|
|
|
var reviewPrompt = new List<object>();
|
|
if (!string.IsNullOrWhiteSpace(reviewSystemText))
|
|
reviewPrompt.Add(new { type = "text", text = reviewSystemText });
|
|
|
|
reviewContext = new ReviewContext
|
|
{
|
|
ModelId = reviewModelId,
|
|
SystemPrompt = reviewPrompt,
|
|
AgentId = reviewAgent.AgentId,
|
|
Workspace = reviewWorkspace,
|
|
UseMemory = reviewAgent.UseMemory,
|
|
WebSearchVersion = reviewAgent.WebSearchVersion,
|
|
ToolChoice = reviewAgent.ToolChoice ?? "any",
|
|
MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3,
|
|
Agent = reviewAgent
|
|
};
|
|
}
|
|
}
|
|
|
|
// Finalizer-Agent vorbereiten (falls konfiguriert)
|
|
FinalizerContext finalizerContext = null;
|
|
if (!string.IsNullOrEmpty(session.FinalizerAgentId))
|
|
{
|
|
var finalizerAgent = await _db.GetAgent(session.FinalizerAgentId);
|
|
if (finalizerAgent != null)
|
|
{
|
|
var finalizerModelId = ResolveModelId(finalizerAgent.Model);
|
|
var finalizerMarked = await _db.GetMarkedMessages(sessionId);
|
|
var finalizerMemDir = _memoryService.GetAgentMemoryDir(finalizerAgent.AgentId);
|
|
var finalizerSkills = await _db.GetAgentSkills(finalizerAgent.AgentId);
|
|
var finalizerWorkspace = workspace;
|
|
var finalizerSystemText = SystemPromptBuilder.Build(
|
|
finalizerAgent, session, finalizerMarked, _memoryService, finalizerMemDir,
|
|
finalizerSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
|
resolvedWorkspace: finalizerWorkspace);
|
|
|
|
var finalizerPrompt = new List<object>();
|
|
if (!string.IsNullOrWhiteSpace(finalizerSystemText))
|
|
finalizerPrompt.Add(new { type = "text", text = finalizerSystemText });
|
|
|
|
finalizerContext = new FinalizerContext
|
|
{
|
|
ModelId = finalizerModelId,
|
|
SystemPrompt = finalizerPrompt,
|
|
AgentId = finalizerAgent.AgentId,
|
|
Workspace = finalizerWorkspace,
|
|
UseMemory = finalizerAgent.UseMemory,
|
|
WebSearchVersion = finalizerAgent.WebSearchVersion,
|
|
ToolChoice = finalizerAgent.ToolChoice ?? "any",
|
|
Agent = finalizerAgent
|
|
};
|
|
}
|
|
}
|
|
|
|
return new ChatContext
|
|
{
|
|
ChainId = chainId,
|
|
SessionId = sessionId,
|
|
ModelId = modelId,
|
|
Workspace = workspace,
|
|
AgentId = agent.AgentId,
|
|
SystemPromptText = systemPromptText,
|
|
SystemPrompt = systemPrompt,
|
|
ToolChoice = toolChoice,
|
|
UseMemory = agent.UseMemory,
|
|
WebSearchVersion = agent.WebSearchVersion,
|
|
MaxIterations = maxIterations,
|
|
Agent = agent,
|
|
Session = session,
|
|
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>
|
|
public async Task<List<object>> BuildHistoryFromDb(string sessionId)
|
|
{
|
|
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)).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 }
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return history;
|
|
}
|
|
}
|