From 4960b57ec7b230675112df80cf380f7fd66b5bd5 Mon Sep 17 00:00:00 2001 From: Meleki Date: Wed, 25 Mar 2026 09:09:02 +0100 Subject: [PATCH] feat: Nvidia API direkt-Anbindung mit Thinking-Mode Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Server-Setting NvidiaApiKey – wenn gesetzt, wird direkt integrate.api.nvidia.com verwendet statt g4f.space Proxy. Thinking-Modelle (kimi-k2, qwq, deepseek-r1) erhalten automatisch chat_template_kwargs und reasoning-Output wird als klappbare Denkprozess-Bubble im Chat angezeigt. Co-Authored-By: Claude Opus 4.6 --- Services/OpenAiService.cs | 87 +++++++++++++++++++++++++++++-- Services/ServerSettingsService.cs | 1 + wwwroot/css/chat.css | 10 ++++ wwwroot/js/chat.js | 12 +++++ wwwroot/js/history.js | 18 +++++++ wwwroot/js/stream-ui.js | 51 ++++++++++++++++++ 6 files changed, 176 insertions(+), 3 deletions(-) diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index f7e01c6..36048b9 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -127,7 +127,11 @@ public class OpenAiService /// public async Task RefreshModelsFromApis() { - _nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia"); + var nvidiaApiKey = _settings.Get("NvidiaApiKey"); + if (!string.IsNullOrEmpty(nvidiaApiKey)) + _nvidiaModels = await FetchModels("https://integrate.api.nvidia.com/v1/models", "Nvidia", nvidiaApiKey); + else + _nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia"); _pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations"); var geminiApiKey = _settings.Get("GeminiApiKey"); @@ -303,6 +307,9 @@ public class OpenAiService ["tool_choice"] = "auto" }; + if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) + payload["chat_template_kwargs"] = new { thinking = true }; + var jsonPayload = JsonSerializer.Serialize(payload); var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); @@ -333,10 +340,12 @@ public class OpenAiService using var reader = new System.IO.StreamReader(stream); var fullText = new StringBuilder(); + var thinkingText = new StringBuilder(); // Tool-Call Akkumulatoren: id → (name, arguments) var toolCallAccumulators = new Dictionary(); string thoughtSignature = null; var textStarted = false; + var thinkingStarted = false; var stopReason = "end_turn"; string line; @@ -367,12 +376,34 @@ public class OpenAiService if (!choice.TryGetProperty("delta", out var delta)) continue; + // Reasoning/Thinking-Delta (Nvidia Thinking-Modelle) + if (delta.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String) + { + var chunk = reasoningProp.GetString(); + if (!string.IsNullOrEmpty(chunk)) + { + if (!thinkingStarted) + { + await SseHelper.TryWriteSse(response, "thinking_start", new { }, sseCt); + thinkingStarted = true; + } + thinkingText.Append(chunk); + await SseHelper.TryWriteSse(response, "thinking_delta", new { text = chunk }, sseCt); + } + } + // Text-Delta if (delta.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) { var chunk = contentProp.GetString(); if (!string.IsNullOrEmpty(chunk)) { + // Thinking-Block beenden falls noch offen + if (thinkingStarted) + { + await SseHelper.TryWriteSse(response, "thinking_end", new { }, sseCt); + thinkingStarted = false; + } if (!textStarted) { await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt); @@ -383,8 +414,6 @@ public class OpenAiService } } - - // Tool-Call Deltas akkumulieren if (delta.TryGetProperty("tool_calls", out var toolCallsProp)) { @@ -465,10 +494,26 @@ public class OpenAiService } } + // Thinking-End senden falls noch offen + if (thinkingStarted) + await SseHelper.TryWriteSse(response, "thinking_end", new { }, sseCt); + // Text-End senden falls Text gestreamt wurde if (textStarted) await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt); + // Thinking-Text in DB speichern + var finalThinking = thinkingText.ToString(); + if (!string.IsNullOrEmpty(finalThinking)) + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iteration, + Role = "assistant", Type = "thinking", + Content = finalThinking + }); + } + // Text in DB speichern var finalText = fullText.ToString(); if (!string.IsNullOrEmpty(finalText)) @@ -669,6 +714,9 @@ public class OpenAiService ["tool_choice"] = "auto" }; + if (provider == "nvidia" && IsNvidiaThinkingModel(modelId)) + payload["chat_template_kwargs"] = new { thinking = true }; + var jsonPayload = JsonSerializer.Serialize(payload); var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); @@ -709,6 +757,21 @@ public class OpenAiService var message = choice.GetProperty("message"); var finishReason = choice.TryGetProperty("finish_reason", out var frProp) ? frProp.GetString() ?? "" : ""; + // Reasoning/Thinking extrahieren (Nvidia Thinking-Modelle) + if (message.TryGetProperty("reasoning", out var reasoningProp) && reasoningProp.ValueKind == JsonValueKind.String) + { + var reasoning = reasoningProp.GetString() ?? ""; + if (!string.IsNullOrEmpty(reasoning)) + { + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "thinking", + Content = reasoning + }); + } + } + // Text extrahieren if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String) { @@ -909,12 +972,30 @@ public class OpenAiService if (!string.IsNullOrEmpty(apiKeyOverride)) providerConfig = (providerConfig.BaseUrl, apiKeyOverride); + if (provider == "nvidia" && string.IsNullOrEmpty(providerConfig.ApiKey)) + { + var nvidiaKey = _settings.Get("NvidiaApiKey"); + if (!string.IsNullOrEmpty(nvidiaKey)) + providerConfig = ("https://integrate.api.nvidia.com/v1", nvidiaKey); + } + if (provider == "gemini" && string.IsNullOrEmpty(providerConfig.ApiKey)) providerConfig = (providerConfig.BaseUrl, _settings.Get("GeminiApiKey")); return (provider, modelId, providerConfig); } + /// + /// Prüft ob ein Nvidia-Modell Thinking-Mode unterstützt (chat_template_kwargs). + /// + private static bool IsNvidiaThinkingModel(string modelId) + { + // Modelle mit Extended-Thinking-Support bei Nvidia + return modelId.Contains("kimi-k2", StringComparison.OrdinalIgnoreCase) + || modelId.Contains("qwq", StringComparison.OrdinalIgnoreCase) + || modelId.Contains("deepseek-r1", StringComparison.OrdinalIgnoreCase); + } + /// /// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format. /// diff --git a/Services/ServerSettingsService.cs b/Services/ServerSettingsService.cs index b844df8..39c9ca7 100644 --- a/Services/ServerSettingsService.cs +++ b/Services/ServerSettingsService.cs @@ -36,6 +36,7 @@ public class ServerSettingsService new SettingDefinition( "ScanDailyHour", "int", "-1", "Key-Scanner Full-Scan Uhrzeit (0-23, -1 = deaktiviert)", "number" ), new SettingDefinition( "GitHubEventScanner", "bool", "false", "GitHub Event Scanner (Echtzeit-Monitoring öffentlicher Pushes)", "toggle" ), new SettingDefinition( "HuggingFaceToken", "string", "", "HuggingFace Token (optional, erhöht Rate-Limits)", "password" ), + new SettingDefinition( "NvidiaApiKey", "string", "", "Nvidia API-Key (direkt statt g4f.space Proxy)", "password" ), new SettingDefinition( "ScannerKeyTypes", "string_array", "anthropic_oauth,krea_ai", "Key-Scanner: Aktive Key-Typen", "checkboxes" ), }; diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 19ce9af..6165dbb 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -1126,6 +1126,16 @@ } } +/* Thinking Box (Nvidia Reasoning) */ + +.thinking-box .tool-box-header .tool-name { + color: var(--warning); +} + +.thinking-box .thinking-content { + padding: var(--box-padding); +} + /* Review Bubbles */ .review-bubble { diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index c1e2f26..bc21433 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -28,6 +28,18 @@ function addChainMetaBubble(d, streamed) { function handleStreamEvent(eventType, data) { switch (eventType) { + case 'thinking_start': + startThinkingBubble(); + break; + + case 'thinking_delta': + appendToThinking(data.text); + break; + + case 'thinking_end': + endThinkingBubble(); + break; + case 'text_start': if (!_streamDiv) startStreamBubble(); break; diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index eb3f3cb..b97bc54 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -259,6 +259,24 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { } break; } + case 'thinking': { + if (msg.role === 'assistant' && msg.content) { + var thinkDiv = document.createElement('div'); + thinkDiv.className = 'tool-box thinking-box' + (archived ? ' archived' : ''); + var thinkHeader = document.createElement('div'); + thinkHeader.className = 'tool-box-header clickable'; + thinkHeader.innerHTML = 'Denkprozess'; + var thinkContent = document.createElement('div'); + thinkContent.className = 'thinking-content markdown'; + thinkContent.innerHTML = renderMarkdown(msg.content); + thinkContent.hidden = true; + thinkHeader.addEventListener('click', function() { thinkContent.hidden = !thinkContent.hidden; }); + thinkDiv.appendChild(thinkHeader); + thinkDiv.appendChild(thinkContent); + fragment.appendChild(thinkDiv); + } + break; + } case 'tool_use': if (msg.toolName === 'ask_user_choice') { _renderChoiceBubbleInFragment(fragment, msgs, _msgIdx); diff --git a/wwwroot/js/stream-ui.js b/wwwroot/js/stream-ui.js index 97307c1..406064e 100644 --- a/wwwroot/js/stream-ui.js +++ b/wwwroot/js/stream-ui.js @@ -52,6 +52,57 @@ function endStreamBubble() { _streamText = ''; } +// ── Thinking Bubble (Nvidia Reasoning) ── + +let _thinkingDiv = null; +let _thinkingText = ''; + +function startThinkingBubble() { + _thinkingDiv = document.createElement('div'); + _thinkingDiv.className = 'tool-box thinking-box streamed'; + var header = document.createElement('div'); + header.className = 'tool-box-header clickable'; + header.innerHTML = 'Denkprozess'; + var content = document.createElement('div'); + content.className = 'thinking-content markdown'; + content.hidden = true; + content.innerHTML = '

'; + header.addEventListener('click', function() { content.hidden = !content.hidden; }); + _thinkingDiv.appendChild(header); + _thinkingDiv.appendChild(content); + _thinkingText = ''; + chatEl().appendChild(_thinkingDiv); + scrollDown(); +} + +function appendToThinking(text) { + if (!_thinkingDiv) startThinkingBubble(); + _thinkingText += text; + var contentEl = _thinkingDiv.querySelector('.thinking-content'); + contentEl.innerHTML = renderMarkdown(_thinkingText); + var cursor = document.createElement('span'); + cursor.className = 'cursor'; + var lastBlock = contentEl.lastElementChild; + if (lastBlock) lastBlock.appendChild(cursor); + else contentEl.appendChild(cursor); + scrollDown(); +} + +function endThinkingBubble() { + if (_thinkingDiv) { + var cursor = _thinkingDiv.querySelector('.cursor'); + if (cursor) cursor.remove(); + var contentEl = _thinkingDiv.querySelector('.thinking-content'); + if (!_thinkingText.trim()) _thinkingDiv.remove(); + else { + contentEl.innerHTML = renderMarkdown(_thinkingText); + contentEl.hidden = true; + } + } + _thinkingDiv = null; + _thinkingText = ''; +} + // ── Review Bubbles (Streaming) ── let _reviewBubble = null;