From 145441fa7ba912795c911a94672db53128e7231d Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 12 Mar 2026 06:34:11 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Gemini=20thought=5Fsignature=20korrekt?= =?UTF-8?q?=20lesen=20und=20zur=C3=BCcksenden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - thought_signature aus delta.tool_calls[n].extra_content.google.thought_signature lesen (korrekter Pfad) - ThoughtSignature-Feld in MessageRecord + DB-Migration (ALTER TABLE messages ADD COLUMN thought_signature) - SaveMessage speichert ThoughtSignature pro Tool-Call - OpenAiService: toolCallAccumulators Tuple um ThoughtSignature erweitert - Tool-Calls mit thought_signature werden mit extra_content.google.thought_signature zurückgesendet (nicht function.thought_signature) - ConvertMessage (History-Rebuild) setzt thought_signature ebenfalls korrekt - ChatService.BuildApiMessages übergibt thought_signature aus DB-Record - Gemini-Modelle dynamisch laden statt hardcodiert - Tool-Call-Akkumulator nutzt id als primären Key (Gemini sendet parallele Calls ohne index) - content=null → content='' für Gemini-Kompatibilität --- ARCHITECTURE.md | 34 +++++---- ARCHITECTURE_QUICK.md | 24 +++++-- Models/MessageRecord.cs | 1 + Services/ChatService.cs | 12 ++-- Services/DatabaseService.cs | 5 +- Services/OpenAiService.cs | 138 ++++++++++++++++++++++++++---------- 6 files changed, 149 insertions(+), 65 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1d9abda..13f6682 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1566,29 +1566,27 @@ Alle History-Queries (initial load, before/after-Paging, Polling) sortieren nach ## Deployment -### deploy.sh +**WICHTIG:** Das Deployment ist ein **manueller Vorgang**, der durch den Benutzer auf dem Server durchgeführt werden muss. Das System führt kein automatisches Deployment durch. -```bash -#!/bin/bash -set -e -cd /app/gott -export PATH="$HOME/.dotnet:$PATH" -systemctl stop gott -dotnet publish -c Release -o Build/Linux/publish -systemctl start gott -echo "OK" -``` +### Goldene Regel für Änderungen +* **Arbeiten Sie AUSSCHLIESSLICH im Verzeichnis `wwwroot/`** für Frontend-Änderungen. +* **Editiere NIEMALS Dateien in `Build/*/publish/wwwroot/`!** Diese Verzeichnisse werden bei jedem Build überschrieben. +* Das Verzeichnis `Build/*/publish/data/gott.db` darf niemals manuell gelöscht, geleert oder direkt modifiziert werden. -### Verzeichnisse +### Deployment-Vorgang (Beispiel für Linux) +Das vorhandene Shell-Skript `deploy.sh` dient lediglich als Hilfestellung für den manuellen Prozess: -- **Quell-Code**: `/app/gott/` (wwwroot/, Services/, etc.) -- **Build-Output**: `/app/gott/Build//publish/` -- **Laufzeit-Daten**: `/app/gott/Build//publish/data/` (DB, Agents, Images, Skills) -- **WICHTIG**: Änderungen IMMER in `wwwroot/` machen, NIEMALS in `Build/*/publish/wwwroot/` +1. **Frontend-Anpassungen vornehmen** (in `wwwroot/`). +2. **Manuelles Deployment** auf dem Server ausführen: + ```bash + # Beispiel-Ablauf: + systemctl stop gott + dotnet publish -c Release -o Build/Linux/publish + systemctl start gott + ``` ### systemd - -Die Anwendung läuft als systemd Service (`gott.service`), Kestrel auf Port 19000. +Die Anwendung ist als systemd-Service konfiguriert und läuft standardmäßig auf Port 19000. --- diff --git a/ARCHITECTURE_QUICK.md b/ARCHITECTURE_QUICK.md index 1fa2eb4..6afcff2 100644 --- a/ARCHITECTURE_QUICK.md +++ b/ARCHITECTURE_QUICK.md @@ -262,11 +262,27 @@ Verwaltung über **Admin-UI** (`#/server-settings`): Liest `appsettings.Producti ## Deployment -```bash -./deploy.sh # dotnet publish → systemctl restart gott -``` +**WICHTIG:** Das Deployment ist ein **manueller Vorgang**, der durch den Benutzer auf dem Server durchgeführt werden muss. Das System führt kein automatisches Deployment durch. -Läuft als `systemd`-Service, Kestrel auf konfigurierbarem Port (Default 19000), typisch hinter Nginx/Caddy Reverse Proxy. +### Goldene Regel für Änderungen +* **Arbeiten Sie AUSSCHLIESSLICH im Verzeichnis `wwwroot/`** für Frontend-Änderungen. +* **Editiere NIEMALS Dateien in `Build/*/publish/wwwroot/`!** Diese Verzeichnisse werden bei jedem Build überschrieben. +* Das Verzeichnis `Build/*/publish/data/gott.db` darf niemals manuell gelöscht, geleert oder direkt modifiziert werden. + +### Deployment-Vorgang (Beispiel für Linux) +Das vorhandene Shell-Skript `deploy.sh` dient lediglich als Hilfestellung für den manuellen Prozess: + +1. **Frontend-Anpassungen vornehmen** (in `wwwroot/`). +2. **Manuelles Deployment** auf dem Server ausführen: + ```bash + # Beispiel-Ablauf: + systemctl stop gott + dotnet publish -c Release -o Build/Linux/publish + systemctl start gott + ``` + +### systemd +Die Anwendung ist als systemd-Service konfiguriert und läuft standardmäßig auf Port 19000. --- diff --git a/Models/MessageRecord.cs b/Models/MessageRecord.cs index e4a3515..c3ff548 100644 --- a/Models/MessageRecord.cs +++ b/Models/MessageRecord.cs @@ -13,6 +13,7 @@ public class MessageRecord public int Iteration { get; set; } // welche Loop-Iteration public int AttachmentCount { get; set; } // Anzahl angehängter Dateien public string AttachmentNames { get; set; } // JSON-Array mit Dateinamen + public string ThoughtSignature { get; set; } // Gemini thought_signature (nur bei tool_use) public string Tag { get; set; } // null = normal, "preparer"/"review"/"finalizer" = Sub-Agent-Message public bool IsMarked { get; set; } // als wichtig markiert public string DeletedOn { get; set; } // null = aktiv, sonst Zeitstempel der Löschung diff --git a/Services/ChatService.cs b/Services/ChatService.cs index 1e1060b..8c551a7 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -362,22 +362,24 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? try { inputEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } catch { inputEl = JsonSerializer.Deserialize("{}"); } + // thought_signature mitführen falls vorhanden (für Gemini-Provider) + object toolUseBlock = !string.IsNullOrEmpty(msg.ThoughtSignature) + ? (object)new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl, thought_signature = msg.ThoughtSignature } + : new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }; + var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; if (lastAssistant is Dictionary dict && dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { - contentList.Add(new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }); + contentList.Add(toolUseBlock); } else { history.Add(new Dictionary { ["role"] = "assistant", - ["content"] = new List - { - new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl } - } + ["content"] = new List { toolUseBlock } }); } } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index fcef019..9660c02 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -135,6 +135,7 @@ public class DatabaseService TryAddColumn(conn, "agents", "tool_choice", "TEXT DEFAULT 'any'"); TryAddColumn(conn, "agents", "custom_commands", "TEXT"); TryAddColumn(conn, "messages", "is_marked", "INTEGER NOT NULL DEFAULT 0"); + TryAddColumn(conn, "messages", "thought_signature", "TEXT"); TryAddColumn(conn, "agents", "auto_commit", "INTEGER NOT NULL DEFAULT 0"); // Migration: auto_commit von INTEGER (0/1) zu TEXT ("", "mention", "forbid") @@ -768,8 +769,8 @@ public class DatabaseService using var conn = CreateConnection(); record.CreatedAt = DateTime.UtcNow.ToString("o"); return await conn.ExecuteScalarAsync(@" - INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, tag, created_at) - VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @Tag, @CreatedAt); + INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, tag, thought_signature, created_at) + VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @Tag, @ThoughtSignature, @CreatedAt); SELECT last_insert_rowid(); ", record); } diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index b2c33a0..7439943 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -173,13 +173,12 @@ public class OpenAiService _nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia"); _pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations"); - // Gemini: nur kostenlose Free-Tier-Modelle (hardcodiert) - _geminiModels = new List - { - new ProviderModel("gemini-2.5-pro", "Gemini 2.5 Pro"), - new ProviderModel("gemini-2.5-flash", "Gemini 2.5 Flash"), - new ProviderModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"), - }; + // Gemini: dynamisch von der API laden (Bearer-Token erforderlich) + var geminiApiKey = Providers["gemini"].ApiKey; + if (!string.IsNullOrEmpty(geminiApiKey)) + _geminiModels = await FetchModels("https://generativelanguage.googleapis.com/v1beta/openai/models", "Gemini", geminiApiKey); + else + Console.WriteLine("[OpenAiService] GeminiApiKey nicht konfiguriert – Gemini-Modelle nicht geladen."); } private async Task> FetchModels(string url, string providerName, string apiKey = "") @@ -363,7 +362,8 @@ public class OpenAiService var fullText = new StringBuilder(); // Tool-Call Akkumulatoren: id → (name, arguments) - var toolCallAccumulators = new Dictionary(); + var toolCallAccumulators = new Dictionary(); + string thoughtSignature = null; var textStarted = false; var stopReason = "end_turn"; @@ -381,13 +381,15 @@ public class OpenAiService catch { continue; } if (!parsed.TryGetProperty("choices", out var choices)) continue; + + foreach (var choice in choices.EnumerateArray()) { // Finish reason if (choice.TryGetProperty("finish_reason", out var finishProp) && finishProp.ValueKind != JsonValueKind.Null) { - var fr = finishProp.GetString() ?? ""; - if (fr == "tool_calls") stopReason = "tool_use"; + var fr = (finishProp.GetString() ?? "").ToLowerInvariant(); + if (fr == "tool_calls" || fr == "function_call") stopReason = "tool_use"; else if (fr == "stop") stopReason = "end_turn"; } @@ -409,29 +411,74 @@ public class OpenAiService } } + + // Tool-Call Deltas akkumulieren if (delta.TryGetProperty("tool_calls", out var toolCallsProp)) { foreach (var tc in toolCallsProp.EnumerateArray()) { - var idx = tc.TryGetProperty("index", out var idxProp) ? idxProp.GetInt32() : 0; + // thought_signature aus tc.extra_content.google.thought_signature (Gemini-spezifisch) + if (tc.TryGetProperty("extra_content", out var extraContent) + && extraContent.TryGetProperty("google", out var googleContent) + && googleContent.TryGetProperty("thought_signature", out var tcTsProp) + && tcTsProp.ValueKind == JsonValueKind.String) + { + var ts = tcTsProp.GetString(); + if (!string.IsNullOrEmpty(ts)) thoughtSignature = ts; + } + + // ID aus diesem Chunk lesen (kann leer sein bei Folge-Deltas) + var chunkId = tc.TryGetProperty("id", out var idProp2) ? (idProp2.GetString() ?? "") : ""; + + // Schlüssel bestimmen: zuerst per id, dann per index + // → verhindert Kollisionen bei parallelen Tool-Calls ohne index (z.B. Gemini) + int idx; + if (!string.IsNullOrEmpty(chunkId)) + { + // Prüfen ob diese ID schon einem Index zugeordnet ist + idx = -1; + foreach (var kv in toolCallAccumulators) + { + if (kv.Value.Id == chunkId) { idx = kv.Key; break; } + } + if (idx == -1) + { + // Neue ID → nächsten freien Index vergeben + idx = toolCallAccumulators.Count; + while (toolCallAccumulators.ContainsKey(idx)) idx++; + } + } + else + { + idx = tc.TryGetProperty("index", out var idxProp) ? idxProp.GetInt32() : 0; + } + if (!toolCallAccumulators.ContainsKey(idx)) - toolCallAccumulators[idx] = ("", "", new StringBuilder()); + toolCallAccumulators[idx] = ("", "", new StringBuilder(), null); - var (accId, accName, accArgs) = toolCallAccumulators[idx]; + var (accId, accName, accArgs, accTs) = toolCallAccumulators[idx]; - if (tc.TryGetProperty("id", out var idProp)) - accId = idProp.GetString() ?? accId; + if (!string.IsNullOrEmpty(chunkId)) + accId = chunkId; if (tc.TryGetProperty("function", out var funcProp)) { if (funcProp.TryGetProperty("name", out var nameProp2)) - accName = nameProp2.GetString() ?? accName; + { + var n = nameProp2.GetString() ?? ""; + if (!string.IsNullOrEmpty(n)) accName = n; + } if (funcProp.TryGetProperty("arguments", out var argsProp)) accArgs.Append(argsProp.GetString() ?? ""); + } - toolCallAccumulators[idx] = (accId, accName, accArgs); + // Globalen thoughtSignature-Wert auch in accTs des ersten Slots übernehmen + if (!string.IsNullOrEmpty(thoughtSignature) && accTs == null && idx == 0) + accTs = thoughtSignature; + + toolCallAccumulators[idx] = (accId, accName, accArgs, accTs); } } } @@ -462,8 +509,8 @@ public class OpenAiService }); } - // Keine Tool-Calls → fertig - if (toolCallAccumulators.Count == 0 || stopReason != "tool_use") + // Keine Tool-Calls → fertig (stopReason als Fallback, primär Count prüfen) + if (toolCallAccumulators.Count == 0) { await FinalizeChain(chain, usage, iteration, stopReason, sw); await TryWriteSse(response, "done", new @@ -482,35 +529,51 @@ public class OpenAiService // Tool-Calls ausführen // Assistant-Message mit tool_calls in die History einfügen var toolCallsForHistory = new List(); - foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators) + foreach (var (_, (tcId, tcName, tcArgs, tcTs)) in toolCallAccumulators) { - toolCallsForHistory.Add(new + // thought_signature pro Tool-Call mitschicken falls vorhanden + // Gemini erwartet sie in extra_content.google.thought_signature (nicht in function.thought_signature) + if (!string.IsNullOrEmpty(tcTs)) { - id = tcId, - type = "function", - function = new { name = tcName, arguments = tcArgs.ToString() } - }); + toolCallsForHistory.Add(new + { + id = tcId, + type = "function", + function = new { name = tcName, arguments = tcArgs.ToString() }, + extra_content = new { google = new { thought_signature = tcTs } } + }); + } + else + { + toolCallsForHistory.Add(new + { + id = tcId, + type = "function", + function = new { name = tcName, arguments = tcArgs.ToString() } + }); + } - // Tool-Use in DB speichern + // In DB speichern – thought_signature als eigenes Feld await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = iteration, Role = "assistant", Type = "tool_use", Content = tcArgs.ToString(), - ToolCallId = tcId, ToolName = tcName + ToolCallId = tcId, ToolName = tcName, + ThoughtSignature = tcTs }); } apiMessages.Add(new { role = "assistant", - content = finalText, + content = string.IsNullOrEmpty(finalText) ? "" : finalText, tool_calls = toolCallsForHistory }); // Tool-Results sammeln var toolResults = new List(); - foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators) + foreach (var (_, (tcId, tcName, tcArgs, _)) in toolCallAccumulators) { // Tool-Start SSE await TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt); @@ -632,12 +695,15 @@ public class OpenAiService var tcInput = block.TryGetProperty("input", out var inputProp) ? JsonSerializer.Serialize(inputProp) : "{}"; - toolCalls.Add(new - { - id = tcId, - type = "function", - function = new { name = tcName, arguments = tcInput } - }); + // thought_signature als eigenes Feld (vom DB-Record direkt in den Block übernommen) + var tcTs = block.TryGetProperty("thought_signature", out var tsProp2) && tsProp2.ValueKind == JsonValueKind.String + ? tsProp2.GetString() : null; + + // Gemini erwartet thought_signature in extra_content.google.thought_signature + if (!string.IsNullOrEmpty(tcTs)) + toolCalls.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcInput }, extra_content = new { google = new { thought_signature = tcTs } } }); + else + toolCalls.Add(new { id = tcId, type = "function", function = new { name = tcName, arguments = tcInput } }); } else if (blockType == "tool_result") { @@ -665,7 +731,7 @@ public class OpenAiService return new { role = "assistant", - content = string.IsNullOrEmpty(text) ? (object)null : text, + content = string.IsNullOrEmpty(text) ? "" : text, tool_calls = toolCalls }; }