diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index 0464834..d431c2f 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -524,6 +524,22 @@ public static class ApiKeysEndpoint var userId = ctx.GetUserId(); if (userId == null) { ctx.Response.StatusCode = 401; return; } + var agentId = ctx.Request.Query["agentId"].FirstOrDefault(); + if (string.IsNullOrEmpty(agentId)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "agentId required" }); + return; + } + + // "none" = Key-Zuweisung entfernen + if (keyId == "none") + { + await db.SetAgentApiKey(userId, agentId, null); + await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId = (string)null, agentId, name = "Kein Key" }); + return; + } + var key = await db.GetApiKey(keyId); if (key == null) { @@ -540,14 +556,6 @@ public static class ApiKeysEndpoint return; } - var agentId = ctx.Request.Query["agentId"].FirstOrDefault(); - if (string.IsNullOrEmpty(agentId)) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsJsonAsync(new { error = "agentId required" }); - return; - } - await db.SetAgentApiKey(userId, agentId, keyId); await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId, agentId, name = key.Name }); } diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index a32fcc3..fe57dc0 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -62,14 +62,8 @@ public static class ChatEndpoint return; } - // API-Key des Users für diesen Agent auflösen + // API-Key des Users für diesen Agent auflösen (kann null sein) var userApiKey = await db.GetAgentApiKeyRecord(userId, ctx.AgentId); - if (userApiKey == null) - { - res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." }); - return; - } if (isChoiceResponse) { @@ -146,12 +140,21 @@ public static class ChatEndpoint // ── Preparer-Agent Phase (vor dem Worker) ── if (ctx.Preparer != null && !isChoiceResponse) { + var preparerApiToken = userApiKey?.ApiKey; + // Anthropic-Preparer braucht zwingend einen Key, OpenAI-Provider können ohne auskommen + if (string.IsNullOrEmpty(ctx.Preparer.Provider) && string.IsNullOrEmpty(preparerApiToken)) + { + res.StatusCode = 400; + await res.WriteAsJsonAsync(new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." }); + return; + } + // Preparer bekommt History MIT seinen eigenen vorherigen Messages (Tag "preparer"), // damit er den Kontext seiner Rückfragen kennt var preparerHistory = await chatService.BuildHistoryFromDb(sessionId, includeTag: "preparer", promptBlocks: ctx.PromptBlocks); var preparerResult = await anthropic.RunPreparer( - res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, userApiKey.ApiKey, cancellationToken); + res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, preparerApiToken, cancellationToken); if (!preparerResult.ShouldForward) { @@ -219,33 +222,31 @@ public static class ChatEndpoint { if (!string.IsNullOrEmpty(ctx.Provider)) { - // OpenAI-kompatibler Provider – für "openai" den User-Key aus DB verwenden - string openAiKeyOverride = null; - if (ctx.Provider == "openai") - { - // Zuerst prüfen ob der ausgewählte Key ein OpenAI-Key ist - if (userApiKey.Provider == "openai") - openAiKeyOverride = userApiKey.ApiKey; - else - { - // Fallback: ersten aktiven OpenAI-Key aus DB holen - openAiKeyOverride = await db.GetFirstActiveKeyByProvider("openai"); - } + // OpenAI-kompatibler Provider – Key des Users für diesen Agent verwenden + string openAiKeyOverride = userApiKey?.ApiKey; - if (string.IsNullOrEmpty(openAiKeyOverride)) - { - await SseHelper.TryWriteSse(res, "error", new { error = "Kein OpenAI API-Key vorhanden. Bitte einen OpenAI-Key in den API-Keys hinzufügen." }, sseCt); - await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt); - return; - } + // OpenAI-Provider braucht zwingend einen Key + if (ctx.Provider == "openai" && string.IsNullOrEmpty(openAiKeyOverride)) + { + await SseHelper.TryWriteSse(res, "error", new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte einen OpenAI-Key zuweisen." }, sseCt); + await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt); + return; } await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, apiKeyOverride: openAiKeyOverride, workCt: workCt, sseCt: sseCt); } else { - // Standard: Anthropic - await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, apiToken: userApiKey.ApiKey, sessionId: sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, useSerperSearch: ctx.Agent?.UseSerperSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); + // Standard: Anthropic – Key wird zwingend benötigt + var anthropicToken = userApiKey?.ApiKey; + if (string.IsNullOrEmpty(anthropicToken)) + { + await SseHelper.TryWriteSse(res, "error", new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." }, sseCt); + await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt); + return; + } + + await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, apiToken: anthropicToken, sessionId: sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, useSerperSearch: ctx.Agent?.UseSerperSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); } } finally diff --git a/Models/ChatContext.cs b/Models/ChatContext.cs index ab295a6..cda5d43 100644 --- a/Models/ChatContext.cs +++ b/Models/ChatContext.cs @@ -36,6 +36,7 @@ public class ChatContext public class SubAgentContext { public string ModelId { get; set; } + public string Provider { get; set; } // "" = Anthropic, "nvidia"/"openai"/etc. = OpenAI-kompatibel public List SystemPrompt { get; set; } public string AgentId { get; set; } public string Workspace { get; set; } diff --git a/Program.cs b/Program.cs index b9c200c..0da72a5 100644 --- a/Program.cs +++ b/Program.cs @@ -96,6 +96,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService(), dbService, chatService, sp.GetRequiredService(), + sp.GetRequiredService(), serverSettings)); // Krea Image Generation @@ -132,6 +133,7 @@ app.Use(async (context, next) => toolService.SetHttpClientFactory(app.Services.GetRequiredService()); app.Services.GetRequiredService().SetTelegramService(app.Services.GetRequiredService()); +app.Services.GetRequiredService().SetOpenAiService(app.Services.GetRequiredService()); await app.Services.GetRequiredService().LoadModelsAsync(); app.UseWebSockets(); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 8aaa9ab..9bcf2a4 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -50,6 +50,12 @@ public class AnthropicService _telegramService = telegramService; } + private OpenAiService _openAiService; + public void SetOpenAiService(OpenAiService openAiService) + { + _openAiService = openAiService; + } + /// /// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle. /// Fire-and-forget – Fehler werden stillschweigend ignoriert. @@ -474,12 +480,25 @@ public class AnthropicService var preparerMessages = new List(historyMessages); var usage = new TokenUsage(); - var (text, iterations) = await RunAgentChat( - response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId, - preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion, - preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage, - 0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent", - cancellationToken, cancellationToken); + string text; + int iterations; + + if (!string.IsNullOrEmpty(preparer.Provider) && _openAiService != null) + { + // OpenAI-kompatibler Preparer + var subCtx = BuildSubAgentChatContext(preparer, chainId, sessionId, apiToken); + text = await _openAiService.ChatNonStreaming(subCtx, preparerMessages); + iterations = 1; + } + else + { + (text, iterations) = await RunAgentChat( + response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId, + preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion, + preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage, + 0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent", + cancellationToken, cancellationToken); + } var result = new PreparerResult { IterationCount = iterations, ResponseText = text }; @@ -566,13 +585,24 @@ public class AnthropicService // Review-Agent bekommt eigene Message-History var reviewMessages = BuildReviewMessages(workerMessages); - // Review-Agent iteriert mit eigenen Tools - var (reviewText, reviewIterations) = await RunAgentChat( - response, review.ModelId, review.SystemPrompt, review.AgentId, - workspace, review.UseMemory, review.WebSearchVersion, - review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage, - baseIterations, "review", review.Agent?.Name ?? "Review-Agent", - workCt, sseCt); + string reviewText; + int reviewIterations; + + if (!string.IsNullOrEmpty(review.Provider) && _openAiService != null) + { + var subCtx = BuildSubAgentChatContext(review, chainId, sessionId, apiToken); + reviewText = await _openAiService.ChatNonStreaming(subCtx, reviewMessages); + reviewIterations = 1; + } + else + { + (reviewText, reviewIterations) = await RunAgentChat( + response, review.ModelId, review.SystemPrompt, review.AgentId, + workspace, review.UseMemory, review.WebSearchVersion, + review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage, + baseIterations, "review", review.Agent?.Name ?? "Review-Agent", + workCt, sseCt); + } var result = new ReviewResult { ReviewIterationCount = reviewIterations }; @@ -636,12 +666,24 @@ public class AnthropicService // Finalizer bekommt die Konversation als Kontext (gleiche Methode wie Review) var finalizerMessages = BuildReviewMessages(workerMessages); - var (text, iterations) = await RunAgentChat( - response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId, - finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion, - finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage, - baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent", - workCt, sseCt); + string text; + int iterations; + + if (!string.IsNullOrEmpty(finalizer.Provider) && _openAiService != null) + { + var subCtx = BuildSubAgentChatContext(finalizer, chainId, sessionId, apiToken); + text = await _openAiService.ChatNonStreaming(subCtx, finalizerMessages); + iterations = 1; + } + else + { + (text, iterations) = await RunAgentChat( + response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId, + finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion, + finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage, + baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent", + workCt, sseCt); + } await SseHelper.TryWriteSse(response, "finalizer_done", new { @@ -769,6 +811,43 @@ public class AnthropicService return messages; } + /// + /// Baut einen ChatContext für Sub-Agents (Preparer/Review/Finalizer) auf, + /// wenn diese einen OpenAI-kompatiblen Provider nutzen. + /// + private ChatContext BuildSubAgentChatContext(SubAgentContext sub, string chainId, string sessionId, string apiToken) + { + // SystemPrompt als Text extrahieren (SubAgent hat List mit { type, text } Blöcken) + var systemText = ""; + if (sub.SystemPrompt != null) + { + var parts = new List(); + foreach (var block in sub.SystemPrompt) + { + var json = JsonSerializer.Serialize(block); + var el = JsonSerializer.Deserialize(json); + if (el.TryGetProperty("text", out var t)) + parts.Add(t.GetString() ?? ""); + } + systemText = string.Join("\n\n", parts); + } + + return new ChatContext + { + ChainId = chainId, + SessionId = sessionId, + ModelId = sub.ModelId, + Provider = sub.Provider, + Workspace = sub.Workspace, + AgentId = sub.AgentId, + SystemPromptText = systemText, + UseMemory = sub.UseMemory, + WebSearchVersion = sub.WebSearchVersion, + MaxIterations = 20, + ApiToken = apiToken + }; + } + /// /// Generischer Agent-Chat (non-streaming für den Agent, aber SSE-Events für den Client). /// Wird für Review-Agent und Finalizer-Agent verwendet. @@ -918,7 +997,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, telegramService: _telegramService, apiToken: apiToken); } + try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, telegramService: _telegramService, apiToken: apiToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1126,7 +1205,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: ctx.Session?.UserId, telegramService: _telegramService, apiToken: ctx.ApiToken); } + try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, userId: ctx.Session?.UserId, telegramService: _telegramService, apiToken: ctx.ApiToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1419,7 +1498,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: userId, telegramService: _telegramService, apiToken: apiToken); } + try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, openAiService: _openAiService, userId: userId, telegramService: _telegramService, apiToken: apiToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } diff --git a/Services/ChatService.cs b/Services/ChatService.cs index bfcdefc..a7e3b1e 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -806,10 +806,12 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? if (!string.IsNullOrWhiteSpace(systemText)) prompt.Add(new { type = "text", text = systemText }); + var (provider, _) = OpenAiService.ParseModelString(modelId); var hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory; return new SubAgentContext { ModelId = modelId, + Provider = provider, SystemPrompt = prompt, AgentId = agent.AgentId, Workspace = workspace, diff --git a/Services/CronjobService.cs b/Services/CronjobService.cs index a405bb4..8a3f5fc 100644 --- a/Services/CronjobService.cs +++ b/Services/CronjobService.cs @@ -15,6 +15,7 @@ public class CronjobService : BackgroundService private readonly DatabaseService _db; private readonly ChatService _chatService; private readonly AnthropicService _anthropicService; + private readonly OpenAiService _openAiService; private readonly TelegramService _telegramService; private readonly ILogger _logger; @@ -22,12 +23,14 @@ public class CronjobService : BackgroundService DatabaseService db, ChatService chatService, AnthropicService anthropicService, + OpenAiService openAiService, TelegramService telegramService, ILogger logger) { _db = db; _chatService = chatService; _anthropicService = anthropicService; + _openAiService = openAiService; _telegramService = telegramService; _logger = logger; } @@ -109,8 +112,12 @@ public class CronjobService : BackgroundService Content = job.Prompt }); - // Non-Streaming Chat ausführen - var responseText = await _anthropicService.ChatNonStreaming(ctx, history); + // Non-Streaming Chat ausführen – Provider-Weiche + string responseText; + if (!string.IsNullOrEmpty(ctx.Provider)) + responseText = await _openAiService.ChatNonStreaming(ctx, history); + else + responseText = await _anthropicService.ChatNonStreaming(ctx, history); // Session-Timestamp aktualisieren await _db.UpdateSessionTimestamp(job.SessionId); @@ -172,7 +179,11 @@ public class CronjobService : BackgroundService ["content"] = new List { new { type = "text", text = summary } } } }; - var notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory); + string notifyResponse; + if (!string.IsNullOrEmpty(notifyCtx.Provider)) + notifyResponse = await _openAiService.ChatNonStreaming(notifyCtx, notifyHistory); + else + notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory); await _db.UpdateSessionTimestamp(job.NotifySessionId); diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 909f637..4908b6d 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -732,6 +732,204 @@ public class OpenAiService } } + /// + /// Non-Streaming Chat für Telegram, Cronjobs und send_to_session. + /// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop). + /// + public async Task ChatNonStreaming( + ChatContext ctx, + List messages, + Func onIteration = null) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var usage = new TokenUsage(); + int iterations = 0; + var finalText = new StringBuilder(); + + var (provider, modelId) = ParseModelString(ctx.ModelId); + if (!Providers.TryGetValue(provider, out var providerConfig)) + throw new Exception($"Unbekannter Provider: '{provider}'"); + + // API-Key Override: User-Key hat Vorrang + if (!string.IsNullOrEmpty(ctx.ApiToken)) + providerConfig = (providerConfig.BaseUrl, ctx.ApiToken); + + // Gemini: Key dynamisch aus ServerSettings + if (provider == "gemini" && string.IsNullOrEmpty(providerConfig.ApiKey)) + providerConfig = (providerConfig.BaseUrl, _settings.Get("GeminiApiKey")); + + var chain = new ChainRecord + { + ChainId = ctx.ChainId, + SessionId = ctx.SessionId, + Model = ctx.ModelId + }; + + // Messages in OpenAI-Format konvertieren + var apiMessages = new List(); + if (!string.IsNullOrEmpty(ctx.SystemPromptText)) + apiMessages.Add(new { role = "system", content = ctx.SystemPromptText }); + + foreach (var msg in messages) + { + var converted = ConvertMessage(msg); + if (converted == null) continue; + if (converted is List toolResultList) + { + foreach (var tr in toolResultList) + apiMessages.Add(tr); + continue; + } + apiMessages.Add(converted); + } + + try + { + while (iterations < ctx.MaxIterations) + { + iterations++; + onIteration?.Invoke(iterations); + + var client = CreateClient(providerConfig.ApiKey); + var payload = new Dictionary + { + ["model"] = modelId, + ["messages"] = apiMessages, + ["stream"] = false, + ["max_tokens"] = 16384, + ["tools"] = OpenAiTools, + ["tool_choice"] = "auto" + }; + + var jsonPayload = JsonSerializer.Serialize(payload); + var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + using var httpResp = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Post, $"{providerConfig.BaseUrl}/chat/completions") { Content = httpContent }); + + var respBody = await httpResp.Content.ReadAsStringAsync(); + + if (!httpResp.IsSuccessStatusCode) + { + var errMsg = $"API {(int)httpResp.StatusCode}: {respBody}"; + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await FinalizeChain(chain, usage, iterations, "error", sw); + throw new Exception($"OpenAI API Fehler: {(int)httpResp.StatusCode}"); + } + + var resp = JsonSerializer.Deserialize(respBody); + + // Usage tracken + if (resp.TryGetProperty("usage", out var usageProp)) + { + if (usageProp.TryGetProperty("prompt_tokens", out var pt)) + usage.InputTokens = pt.GetInt64(); + if (usageProp.TryGetProperty("completion_tokens", out var ct2)) + usage.OutputTokens = ct2.GetInt64(); + } + + // Erste Choice auswerten + var choices = resp.GetProperty("choices"); + if (choices.GetArrayLength() == 0) break; + + var choice = choices[0]; + var message = choice.GetProperty("message"); + var finishReason = choice.TryGetProperty("finish_reason", out var frProp) ? frProp.GetString() ?? "" : ""; + + // Text extrahieren + if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) + { + var text = contentProp.GetString() ?? ""; + if (!string.IsNullOrEmpty(text)) + { + finalText.Append(text); + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "text", + Content = text + }); + } + } + + // Tool-Calls prüfen + if (!message.TryGetProperty("tool_calls", out var toolCallsProp) || toolCallsProp.GetArrayLength() == 0) + { + await FinalizeChain(chain, usage, iterations, "end_turn", sw); + break; + } + + // Assistant-Message mit tool_calls in History + var assistantMsg = JsonSerializer.Deserialize(message.GetRawText()); + apiMessages.Add(new + { + role = "assistant", + content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : "", + tool_calls = JsonSerializer.Deserialize>(toolCallsProp.GetRawText()) + }); + + // Tool-Calls ausführen + var toolResults = new List(); + foreach (var tc in toolCallsProp.EnumerateArray()) + { + var tcId = tc.GetProperty("id").GetString() ?? ""; + var func = tc.GetProperty("function"); + var tcName = func.GetProperty("name").GetString() ?? ""; + var tcArgs = func.TryGetProperty("arguments", out var argsProp) ? argsProp.GetString() ?? "{}" : "{}"; + + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "tool_use", + Content = tcArgs, + ToolCallId = tcId, ToolName = tcName + }); + + JsonElement inputEl; + try { inputEl = JsonSerializer.Deserialize(tcArgs); } + catch { inputEl = JsonSerializer.Deserialize("{}"); } + + string toolResult; + bool isError = false; + try { toolResult = await _toolService.ExecuteTool(tcName, inputEl, ctx.Workspace); } + catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } + + toolResults.Add(new + { + role = "tool", + tool_call_id = tcId, + content = toolResult + }); + + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "tool", Type = "tool_result", + Content = toolResult, + ToolCallId = tcId, ToolName = tcName, + IsError = isError + }); + } + + foreach (var tr in toolResults) + apiMessages.Add(tr); + } + } + catch + { + if (iterations > 0) + await FinalizeChain(chain, usage, iterations, "error", sw); + throw; + } + + return finalText.Length > 0 ? finalText.ToString() : "(Keine Textantwort)"; + } + /// /// Konvertiert eine Anthropic-format Message in das OpenAI-format. /// diff --git a/Services/TelegramService.cs b/Services/TelegramService.cs index a2c6b5b..383b5b5 100644 --- a/Services/TelegramService.cs +++ b/Services/TelegramService.cs @@ -15,6 +15,7 @@ public class TelegramService private readonly DatabaseService _db; private readonly ChatService _chatService; private readonly AnthropicService _anthropic; + private readonly OpenAiService _openAi; private readonly ServerSettingsService _settings; public TelegramService( @@ -22,12 +23,14 @@ public class TelegramService DatabaseService db, ChatService chatService, AnthropicService anthropic, + OpenAiService openAi, ServerSettingsService settings) { _httpFactory = httpFactory; _db = db; _chatService = chatService; _anthropic = anthropic; + _openAi = openAi; _settings = settings; } @@ -197,11 +200,23 @@ public class TelegramService Content = userText }); - var reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) => + string reply; + if (!string.IsNullOrEmpty(ctx.Provider)) { - if (iteration % 3 == 0) - await SendChatAction(chatId, botToken, "typing"); - }); + reply = await _openAi.ChatNonStreaming(ctx, history, onIteration: async (iteration) => + { + if (iteration % 3 == 0) + await SendChatAction(chatId, botToken, "typing"); + }); + } + else + { + reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) => + { + if (iteration % 3 == 0) + await SendChatAction(chatId, botToken, "typing"); + }); + } await SendLongMessage(chatId, botToken, reply); } diff --git a/Services/ToolService.cs b/Services/ToolService.cs index 001da3d..4a04fc5 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -390,7 +390,7 @@ public class ToolService }; } - public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, string userId = null, TelegramService telegramService = null, string apiToken = null) + public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, OpenAiService openAiService = null, string userId = null, TelegramService telegramService = null, string apiToken = null) { switch (name) { @@ -742,7 +742,7 @@ public class ToolService } case "send_to_session": { - if (db == null || chatService == null || anthropicService == null) + if (db == null || chatService == null || (anthropicService == null && openAiService == null)) return "Fehler: send_to_session nicht verfügbar"; var sessionIdOrName = input.GetProperty("session_id_or_name").GetString() ?? ""; @@ -767,7 +767,10 @@ public class ToolService var ctx = await chatService.PrepareContext(targetSessionId, userId: userId); if (ctx == null) return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)"; - ctx.ApiToken = apiToken; + + // API-Key des Ziel-Agents auflösen (nicht den Key des aufrufenden Agents nutzen) + var targetApiKey = await db.GetAgentApiKeyRecord(userId, ctx.AgentId); + ctx.ApiToken = targetApiKey?.ApiKey ?? apiToken; // History aufbauen – User-Message NICHT in DB speichern (wie Cronjob) // Stattdessen als kontextualisierte Nachricht in die History injizieren @@ -780,7 +783,10 @@ public class ToolService try { - await anthropicService.ChatNonStreaming(ctx, history); + if (!string.IsNullOrEmpty(ctx.Provider) && openAiService != null) + await openAiService.ChatNonStreaming(ctx, history); + else + await anthropicService.ChatNonStreaming(ctx, history); } catch (Exception ex) { diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index 1ce0224..f57d297 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -152,6 +152,36 @@ async function _showKeySwitchModal() { var list = document.createElement('div'); list.className = 'key-switch-list'; + // "Kein Key"-Option als erste Zeile + var noneRow = document.createElement('div'); + noneRow.className = 'key-switch-row'; + if (!selectedKeyId) noneRow.classList.add('active'); + var noneInfo = document.createElement('div'); + noneInfo.className = 'key-switch-info'; + var noneName = document.createElement('div'); + noneName.className = 'key-switch-name'; + noneName.textContent = 'Kein Key'; + noneInfo.appendChild(noneName); + var noneMeta = document.createElement('div'); + noneMeta.className = 'key-switch-meta'; + noneMeta.textContent = 'Agent ohne API-Key nutzen'; + noneInfo.appendChild(noneMeta); + noneRow.appendChild(noneInfo); + if (selectedKeyId) { + noneRow.classList.add('clickable'); + noneRow.addEventListener('click', async function() { + noneRow.classList.add('loading-state'); + try { + await apiSelectApiKey('none', getCurrentAgentId()); + close(); + await _initChatUsageBadge(); + } catch(e) { + noneRow.classList.remove('loading-state'); + } + }); + } + list.appendChild(noneRow); + keys.forEach(function(key) { var row = document.createElement('div'); row.className = 'key-switch-row';