Files
gott/Services/ChatService.cs
T
Meleki 4e1ed81f37 feat: DefaultPath als Base-Path für User-Workspaces
- WorkspaceHelper.cs: Zentrale Workspace-Auflösung (relativ → absolut)
- Session-Workspace wird relativ zum User-DefaultPath gespeichert
- System-Prompt bekommt aufgelösten absoluten Pfad
- Path-Traversal-Schutz: '..' in Workspace-Pfaden blockiert
- Browse-API gibt '..' nicht mehr als Eintrag zurück
- Folder-Browser: basePath-Limit, Back-Button navigiert zum Parent
- DefaultPath nur durch Admin bearbeitbar (Profil: readonly)
- Legacy: Absolute Pfade in session.Workspace weiterhin kompatibel
2026-03-05 22:49:09 +01:00

293 lines
12 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)
{
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)
: 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
});
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 });
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
};
}
/// <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;
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)
{
var lastUserMsg = msgs.LastOrDefault(m => m.Role == "user" && m.Type == "text");
lastUserMsgId = lastUserMsg?.Id;
}
// Für ältere Chains: nur Text-Messages (wie Frontend es vorher gemacht hat)
if (!isLastChain)
{
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;
}
}