feat: Gemini thought_signature korrekt lesen und zurücksenden

- 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
This commit is contained in:
Meleki committed 2026-03-12 06:34:11 +01:00
1 parent e87455ffe9
commit 145441fa7b
6 files changed
+139 -55

No files matched your search

+13 -15
View File
@@ -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.
### 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
#!/bin/bash
set -e
cd /app/gott
export PATH="$HOME/.dotnet:$PATH"
# Beispiel-Ablauf:
systemctl stop gott
dotnet publish -c Release -o Build/Linux/publish
systemctl start gott
echo "OK"
```
### Verzeichnisse
- **Quell-Code**: `/app/gott/` (wwwroot/, Services/, etc.)
- **Build-Output**: `/app/gott/Build/<OS>/publish/`
- **Laufzeit-Daten**: `/app/gott/Build/<OS>/publish/data/` (DB, Agents, Images, Skills)
- **WICHTIG**: Änderungen IMMER in `wwwroot/` machen, NIEMALS in `Build/*/publish/wwwroot/`
### 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.
---
+18 -2
View File
@@ -262,11 +262,27 @@ Verwaltung über **Admin-UI** (`#/server-settings`): Liest `appsettings.Producti
## Deployment
**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.
### 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
./deploy.sh # dotnet publish → systemctl restart gott
# Beispiel-Ablauf:
systemctl stop gott
dotnet publish -c Release -o Build/Linux/publish
systemctl start gott
```
Läuft als `systemd`-Service, Kestrel auf konfigurierbarem Port (Default 19000), typisch hinter Nginx/Caddy Reverse Proxy.
### systemd
Die Anwendung ist als systemd-Service konfiguriert und läuft standardmäßig auf Port 19000.
---
+1
View File
@@ -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
+7 -5
View File
@@ -362,22 +362,24 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
try { inputEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
// 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<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 });
contentList.Add(toolUseBlock);
}
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 }
}
["content"] = new List<object> { toolUseBlock }
});
}
}
+3 -2
View File
@@ -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<long>(@"
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);
}
+97 -31
View File
@@ -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<ProviderModel>
{
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<List<ProviderModel>> 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<int, (string Id, string Name, StringBuilder Args)>();
var toolCallAccumulators = new Dictionary<int, (string Id, string Name, StringBuilder Args, string ThoughtSignature)>();
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,7 +529,21 @@ public class OpenAiService
// Tool-Calls ausführen
// Assistant-Message mit tool_calls in die History einfügen
var toolCallsForHistory = new List<object>();
foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators)
foreach (var (_, (tcId, tcName, tcArgs, tcTs)) in toolCallAccumulators)
{
// 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))
{
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
{
@@ -490,27 +551,29 @@ public class OpenAiService
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<object>();
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
};
}