- 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
293 lines
12 KiB
C#
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;
|
|
}
|
|
}
|