diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index fd2618b..c7356f5 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -240,7 +240,7 @@ public static class ChatEndpoint 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); + await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, apiKeyOverride: openAiKeyOverride, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, sseCt: sseCt); } else { diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index f681547..1d37577 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -317,8 +317,54 @@ public static class SessionEndpoint var toolChoice = hasTools || !string.IsNullOrEmpty(agent.WebSearchVersion) ? "any" : null; var effectiveChoice = toolChoice == "any" ? "any" : "auto"; - // Payload zusammenbauen (Anthropic-Format) - var payload = new Dictionary + // OpenAI-kompatible Provider: Payload im tatsächlichen API-Format anzeigen + if (previewProvider) + { + var (provider, _) = OpenAiService.ParseModelString(modelId); + var maxTokens = provider == "minimax" ? 32768 : 16384; + + // Messages konvertieren (Anthropic → OpenAI-Format) + var openAiMessages = OpenAiService.BuildApiMessages(systemPromptText, messages); + + // Tools konvertieren (input_schema → function/parameters) + var openAiTools = tools.Count > 0 + ? OpenAiService.ConvertTo_openAiToolDefs(tools.ToArray()) + : new List(); + + var payload = new Dictionary + { + ["model"] = modelId, + ["max_tokens"] = maxTokens, + ["messages"] = openAiMessages, + ["stream"] = true, + ["stream_options"] = new { include_usage = true } + }; + + if (openAiTools.Count > 0) + { + payload["tools"] = openAiTools; + payload["tool_choice"] = "auto"; + } + + if (provider == "minimax") + payload["extra_body"] = new { reasoning_split = true }; + + if (provider == "nvidia") + { + var nvidiaModelId = modelId.Contains(":") ? modelId.Split(':')[1] : modelId; + if (nvidiaModelId.Contains("kimi-k2", StringComparison.OrdinalIgnoreCase) + || nvidiaModelId.Contains("qwq", StringComparison.OrdinalIgnoreCase) + || nvidiaModelId.Contains("deepseek-r1", StringComparison.OrdinalIgnoreCase)) + payload["chat_template_kwargs"] = new { thinking = true }; + } + + ctx.Response.ContentType = "application/json; charset=utf-8"; + await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + // Anthropic-Format Payload + var anthropicPayload = new Dictionary { ["model"] = modelId, ["max_tokens"] = 16384, @@ -327,16 +373,16 @@ public static class SessionEndpoint }; if (systemPrompt.Count > 0) - payload["system"] = systemPrompt; + anthropicPayload["system"] = systemPrompt; if (tools.Count > 0) { - payload["tools"] = tools; - payload["tool_choice"] = new { type = effectiveChoice }; + anthropicPayload["tools"] = tools; + anthropicPayload["tool_choice"] = new { type = effectiveChoice }; } ctx.Response.ContentType = "application/json; charset=utf-8"; - await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + await ctx.Response.WriteAsync(JsonSerializer.Serialize(anthropicPayload, new JsonSerializerOptions { WriteIndented = true })); } private static async Task HandleStreaming(HttpContext ctx, DatabaseService db, StreamingTracker streamingTracker) diff --git a/Program.cs b/Program.cs index 556a54e..a01247b 100644 --- a/Program.cs +++ b/Program.cs @@ -95,7 +95,7 @@ builder.Services.AddSingleton(sp => // OpenAI-kompatibler Service (Nvidia etc.) builder.Services.AddSingleton(sp => - new OpenAiService(sp.GetRequiredService(), dbService, sp.GetRequiredService(), serverSettings, dataDir)); + new OpenAiService(sp.GetRequiredService(), dbService, sp.GetRequiredService(), serverSettings, dataDir, vectorSearchService)); // Telegram Bot (pro Session – Token aus Session-Config) builder.Services.AddSingleton(sp => diff --git a/Services/AnthropicService.Cli.cs b/Services/AnthropicService.Cli.cs index 606cc21..fd8a99a 100644 --- a/Services/AnthropicService.Cli.cs +++ b/Services/AnthropicService.Cli.cs @@ -92,7 +92,7 @@ public partial class AnthropicService { try { - var autoCtx = await BuildAutoContextAsync(messages, chainId, agentId); + var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId); if (autoCtx != null) { systemText.AppendLine(autoCtx); diff --git a/Services/AnthropicService.Streaming.cs b/Services/AnthropicService.Streaming.cs index 0a2a482..c7f24e9 100644 --- a/Services/AnthropicService.Streaming.cs +++ b/Services/AnthropicService.Streaming.cs @@ -62,7 +62,7 @@ public partial class AnthropicService { try { - var autoCtx = await BuildAutoContextAsync(messages, chainId, agentId); + var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId); if (autoCtx != null) { systemPrompt.Add(new { type = "text", text = autoCtx }); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index b7ee941..ae7c851 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -138,7 +138,7 @@ public partial class AnthropicService } /// Extrahiert die letzten N User-Texte für Query-Erweiterung. - private static string ExtractRecentUserTexts(List messages, int count = 3, int maxLength = 500) + internal static string ExtractRecentUserTexts(List messages, int count = 3, int maxLength = 500) { var texts = new List(); for (int i = messages.Count - 1; i >= 0 && texts.Count < count; i--) @@ -171,21 +171,21 @@ public partial class AnthropicService /// /// Baut den AutoContext-String aus Embedding-Suche. - /// Shared zwischen StreamChat und StreamChatViaCli. + /// Shared zwischen StreamChat, StreamChatViaCli und OpenAiService. /// - private async Task BuildAutoContextAsync(List messages, string chainId, string agentId) + internal static async Task BuildAutoContextAsync(VectorSearchService vectorSearch, List messages, string chainId, string agentId) { - if (_vectorSearch == null || messages.Count == 0) return null; + if (vectorSearch == null || messages.Count == 0) return null; // Query-Erweiterung: letzte 2-3 User-Messages statt nur die letzte var queryText = ExtractRecentUserTexts(messages, count: 3, maxLength: 500); if (string.IsNullOrWhiteSpace(queryText)) return null; - var contextResults = await _vectorSearch.SearchAsync(queryText, agentId, topN: 5, minScore: 0.35f); + var contextResults = await vectorSearch.SearchAsync(queryText, agentId, topN: 5, minScore: 0.35f); // Note: threshold lowered from 0.45 to 0.35 because hybrid search is now more precise contextResults.RemoveAll(r => r.ChainId == chainId || (r.TextPreview?.Length ?? 0) < 20); - var memoryResults = await _vectorSearch.SearchMemoryFilesAsync(queryText, agentId, topN: 3, minScore: 0.35f); + var memoryResults = await vectorSearch.SearchMemoryFilesAsync(queryText, agentId, topN: 3, minScore: 0.35f); if (contextResults.Count == 0 && memoryResults.Count == 0) return null; diff --git a/Services/ChatService.cs b/Services/ChatService.cs index 5ef007e..2da4950 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -299,12 +299,12 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? ["content"] = new List { new { type = "text", text = msg.Content } } }); } - else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content)) + else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrWhiteSpace(msg.Content)) { history.Add(new Dictionary { ["role"] = "assistant", - ["content"] = new List { new { type = "text", text = msg.Content } } + ["content"] = new List { new { type = "text", text = msg.Content.Trim() } } }); } } @@ -383,11 +383,14 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? } else if (msg.Role == "assistant" && msg.Type == "text") { - if (!string.IsNullOrEmpty(msg.Content)) + // Trim: Nach -Extraktion bleibt oft nur "\n" als Text übrig. + // Whitespace-only Text nicht in die History aufnehmen (verursacht leere Bubbles). + var trimmedContent = msg.Content?.Trim(); + if (!string.IsNullOrEmpty(trimmedContent)) { // Wenn die letzte Message bereits assistant ist (z.B. mit Thinking-Block // aus derselben Iteration), Text dort anhängen statt neue Message zu starten. - AppendToLastMessage(history, "assistant", new { type = "text", text = msg.Content }); + AppendToLastMessage(history, "assistant", new { type = "text", text = trimmedContent }); } } else if (msg.Role == "assistant" && msg.Type == "tool_use") @@ -436,8 +439,11 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? AppendToLastMessage(history, "assistant", resultEl); } - else if (msg.Role == "user" && msg.Type == "tool_result") + else if ((msg.Role == "user" || msg.Role == "tool") && msg.Type == "tool_result") { + // Anthropic speichert tool_results mit Role="user", OpenAI mit Role="tool". + // Beide werden in Anthropic-Format (user-Message mit tool_result-Block) eingefügt, + // da BuildApiMessages später das Format für OpenAI-Provider konvertiert. var toolResultBlock = new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }; AppendToLastMessage(history, "user", toolResultBlock); } diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 5252ddb..97876ce 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -23,6 +23,7 @@ public class OpenAiService private readonly IHttpClientFactory _httpFactory; private readonly DatabaseService _db; private readonly ToolService _toolService; + private readonly VectorSearchService _vectorSearch; // Provider-Keys (statisch für ChatService.ResolveModelId) public static readonly HashSet ProviderKeys = new() { "nvidia", "pollinations", "gemini", "openai", "minimax" }; @@ -36,7 +37,7 @@ public class OpenAiService /// Konvertiert Anthropic-Format Tool-Definitionen (input_schema) in OpenAI-Format (function/parameters). /// Single source of truth: ToolService.GetToolDefinitions() /// - private static List ConvertTo_openAiToolDefs(object[] anthropicTools) + public static List ConvertTo_openAiToolDefs(object[] anthropicTools) { var result = new List(); foreach (var tool in anthropicTools) @@ -86,11 +87,12 @@ public class OpenAiService private readonly ServerSettingsService _settings; - public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService, ServerSettingsService settings, string dataDir) + public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService, ServerSettingsService settings, string dataDir, VectorSearchService vectorSearch = null) { _httpFactory = httpFactory; _db = db; _toolService = toolService; + _vectorSearch = vectorSearch; _settings = settings; _cacheFile = Path.Combine(dataDir, "models_cache.json"); Providers = new Dictionary @@ -295,6 +297,7 @@ public class OpenAiService string agentId = null, int maxIterations = 10, string apiKeyOverride = null, + bool useAutoContext = true, CancellationToken workCt = default, CancellationToken sseCt = default) { @@ -328,6 +331,32 @@ public class OpenAiService try { + // ── Automatische Kontext-Injektion via Embedding-Suche ── + if (useAutoContext) + { + try + { + var autoCtx = await AnthropicService.BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId); + if (autoCtx != null) + { + // Auto-Context an System-Prompt anhängen + systemPromptText = (systemPromptText ?? "") + "\n" + autoCtx; + await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt); + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Role = "system", + Type = "auto_context", + Content = autoCtx + }); + } + } + catch (Exception ex) + { + Console.WriteLine($"[AutoContext/OpenAI] Error: {ex.Message}"); + } + } + var apiMessages = BuildApiMessages(systemPromptText, messages); // Agentic Loop @@ -786,8 +815,8 @@ public class OpenAiService }); } - // Text in DB speichern - var finalText = fullText.ToString(); + // Text in DB speichern (Whitespace-only nach ignorieren) + var finalText = fullText.ToString().Trim(); if (!string.IsNullOrEmpty(finalText)) { await _db.SaveMessage(new MessageRecord @@ -925,6 +954,7 @@ public class OpenAiService { name = tcName, id = tcId, + input = tcArgs.ToString(), result = toolResult, isError }, sseCt); @@ -1029,7 +1059,32 @@ public class OpenAiService Model = ctx.ModelId }; - var apiMessages = BuildApiMessages(ctx.SystemPromptText, messages); + // ── Automatische Kontext-Injektion via Embedding-Suche ── + var systemPromptText = ctx.SystemPromptText; + if (ctx.Agent?.UseAutoContext ?? true) + { + try + { + var autoCtx = await AnthropicService.BuildAutoContextAsync(_vectorSearch, messages, ctx.ChainId, ctx.AgentId); + if (autoCtx != null) + { + systemPromptText = (systemPromptText ?? "") + "\n" + autoCtx; + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, + Role = "system", + Type = "auto_context", + Content = autoCtx + }); + } + } + catch (Exception ex) + { + Console.WriteLine($"[AutoContext/OpenAI-NonStreaming] Error: {ex.Message}"); + } + } + + var apiMessages = BuildApiMessages(systemPromptText, messages); try { @@ -1039,12 +1094,13 @@ public class OpenAiService onIteration?.Invoke(iterations); var client = CreateClient(providerConfig.ApiKey); + var maxTokens = provider == "minimax" ? 32768 : 16384; var payload = new Dictionary { ["model"] = modelId, ["messages"] = apiMessages, ["stream"] = false, - ["max_tokens"] = 16384, + ["max_tokens"] = maxTokens, ["tools"] = _openAiToolDefs, ["tool_choice"] = "auto" }; @@ -1052,6 +1108,9 @@ public class OpenAiService if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) payload["chat_template_kwargs"] = new { thinking = true }; + if (provider == "minimax") + payload["extra_body"] = new { reasoning_split = true }; + var jsonPayload = JsonSerializer.Serialize(payload); var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); @@ -1364,7 +1423,7 @@ public class OpenAiService /// /// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format. /// - private static List BuildApiMessages(string systemPromptText, List messages) + public static List BuildApiMessages(string systemPromptText, List messages) { var apiMessages = new List(); if (!string.IsNullOrEmpty(systemPromptText)) diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 63a3d2b..6d4d116 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -220,7 +220,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { // ── Grouped Review/Preparer/Finalizer Bubble (tag-based) ── if (msg.tag && (msg.tag === 'review' || msg.tag === 'preparer' || msg.tag === 'finalizer')) { // Collect all consecutive messages with the same tag - var tagGroup = [msg]; + const tagGroup = [msg]; while (_msgIdx + 1 < msgs.length && msgs[_msgIdx + 1].tag === msg.tag) { _msgIdx++; tagGroup.push(msgs[_msgIdx]); @@ -250,6 +250,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { _addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked }); fragment.appendChild(div); } else if (msg.role === 'assistant') { + // Whitespace-only Content nicht rendern (z.B. "\n" nach -Extraktion) + if (!msg.content || !msg.content.trim()) break; const div = _createMessageBubble({ role: 'assistant', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived }); div.innerHTML = renderMarkdown(msg.content); parseFluentEmojis(div); @@ -261,12 +263,12 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { } case 'auto_context': { if (msg.content) { - var acDiv = document.createElement('div'); + const acDiv = document.createElement('div'); acDiv.className = 'tool-box auto-context-box' + (archived ? ' archived' : ''); - var acHeader = document.createElement('div'); + const acHeader = document.createElement('div'); acHeader.className = 'tool-box-header clickable'; acHeader.innerHTML = 'Auto-Context'; - var acContent = document.createElement('div'); + const acContent = document.createElement('div'); acContent.className = 'auto-context-content markdown'; acContent.innerHTML = renderMarkdown(msg.content.replace(/<\/?recalled_context>/g, '').trim()); acContent.hidden = true; @@ -279,12 +281,12 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { } case 'thinking': { if (msg.role === 'assistant' && msg.content) { - var thinkDiv = document.createElement('div'); + const thinkDiv = document.createElement('div'); thinkDiv.className = 'tool-box thinking-box' + (archived ? ' archived' : ''); - var thinkHeader = document.createElement('div'); + const thinkHeader = document.createElement('div'); thinkHeader.className = 'tool-box-header clickable'; thinkHeader.innerHTML = 'Denkprozess'; - var thinkContent = document.createElement('div'); + const thinkContent = document.createElement('div'); thinkContent.className = 'thinking-content markdown'; thinkContent.innerHTML = renderMarkdown(msg.content); thinkContent.hidden = true; @@ -301,17 +303,18 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { continue; } if (!isToolVisible(msg.toolName)) continue; - var toolDiv = document.createElement('div'); + const toolDiv = document.createElement('div'); toolDiv.className = 'tool-box' + (archived ? ' archived' : ''); toolDiv.dataset.toolName = msg.toolName; + if (msg.toolCallId) toolDiv.dataset.toolCallId = msg.toolCallId; renderToolBoxContent(toolDiv, msg.toolName, msg.content, null, false); fragment.appendChild(toolDiv); break; case 'web_search_result': { if (!isToolVisible('web_search')) continue; - var wsSources = _extractWebSearchSources(msg.content); + const wsSources = _extractWebSearchSources(msg.content); if (wsSources.length === 0) continue; - var wsDiv = document.createElement('div'); + const wsDiv = document.createElement('div'); wsDiv.className = 'tool-box' + (archived ? ' archived' : ''); wsDiv.dataset.toolName = 'web_search'; renderToolBoxContent(wsDiv, 'web_search', null, null, false, wsSources); @@ -320,10 +323,18 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { } case 'tool_result': { if (msg._choiceHandled) continue; - const prevBoxes = fragment.querySelectorAll('.tool-box'); - const lastToolBox = prevBoxes[prevBoxes.length - 1]; + // Passendes Tool-Box per toolCallId finden (falls verfügbar), + // sonst Fallback auf letztes Tool-Box (ältere DB-Einträge ohne toolCallId) + let lastToolBox = null; + if (msg.toolCallId) { + lastToolBox = fragment.querySelector('.tool-box[data-tool-call-id="' + msg.toolCallId + '"]'); + } + if (!lastToolBox) { + const prevBoxes = fragment.querySelectorAll('.tool-box'); + lastToolBox = prevBoxes[prevBoxes.length - 1]; + } if (lastToolBox) { - var existingResult = lastToolBox.querySelector('.tool-result'); + const existingResult = lastToolBox.querySelector('.tool-result'); // todo_write: nachträglich die Todo-Liste rendern (sichtbar) if (lastToolBox.dataset.toolName === 'todo_write' && !lastToolBox.querySelector('.todo-list')) { lastToolBox.appendChild(buildTodoList(msg.content || '', msg.isError)); @@ -339,7 +350,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { rd.hidden = true; lastToolBox.appendChild(rd); // Header clickable machen - var hdr = lastToolBox.querySelector('.tool-box-header'); + const hdr = lastToolBox.querySelector('.tool-box-header'); if (hdr && !hdr.classList.contains('clickable')) { hdr.classList.add('clickable'); hdr.addEventListener('click', function() { rd.hidden = !rd.hidden; });