fix(openai): Auto-Context, Tool-Result-Matching, Payload-Preview, leere Bubbles

- Auto-Context (Embedding-Suche) für OpenAI-Provider aktiviert: BuildAutoContextAsync
  auf internal static refactored und in StreamChat + ChatNonStreaming integriert
- Tool-Results mit Role="tool" (OpenAI-Konvention) werden in BuildHistoryFromDb
  jetzt korrekt erkannt (vorher nur Role="user"/Anthropic)
- Payload-Preview zeigt für OpenAI-Provider echtes API-Format (function-Wrapper,
  tool_choice:"auto", max_tokens, stream_options, extra_body)
- Tool-Input (Argumente) wird im SSE tool_end-Event mitgesendet (fehlte im OpenAI-Pfad)
- Leere Bubbles durch Whitespace-only Text nach </think>-Extraktion behoben
  (Trim in DB-Save, BuildHistoryFromDb und Frontend-Guard)
- History-Toggle-Bug: var→const in for-Loop (Block-Scoping für Closures),
  tool_result-Matching per data-tool-call-id statt blindes letztes Element
- Non-Streaming max_tokens für MiniMax auf 32768 + extra_body.reasoning_split

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.6 committed 2026-05-14 11:16:40 +02:00
1 parent ecddbe5f36
commit 1fe04025d3
9 files changed
+164 -42

No files matched your search

+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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 });
+6 -6
View File
@@ -138,7 +138,7 @@ public partial class AnthropicService
}
/// <summary>Extrahiert die letzten N User-Texte für Query-Erweiterung.</summary>
private static string ExtractRecentUserTexts(List<object> messages, int count = 3, int maxLength = 500)
internal static string ExtractRecentUserTexts(List<object> messages, int count = 3, int maxLength = 500)
{
var texts = new List<string>();
for (int i = messages.Count - 1; i >= 0 && texts.Count < count; i--)
@@ -171,21 +171,21 @@ public partial class AnthropicService
/// <summary>
/// Baut den AutoContext-String aus Embedding-Suche.
/// Shared zwischen StreamChat und StreamChatViaCli.
/// Shared zwischen StreamChat, StreamChatViaCli und OpenAiService.
/// </summary>
private async Task<string> BuildAutoContextAsync(List<object> messages, string chainId, string agentId)
internal static async Task<string> BuildAutoContextAsync(VectorSearchService vectorSearch, List<object> 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;
+11 -5
View File
@@ -299,12 +299,12 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
["content"] = new List<object> { 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<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
["content"] = new List<object> { 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 </think>-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);
}
+66 -7
View File
@@ -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<string> 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()
/// </summary>
private static List<object> ConvertTo_openAiToolDefs(object[] anthropicTools)
public static List<object> ConvertTo_openAiToolDefs(object[] anthropicTools)
{
var result = new List<object>();
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<string, (string BaseUrl, string ApiKey)>
@@ -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 </think> 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<string, object>
{
["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
/// <summary>
/// Konvertiert Anthropic-Format Messages in OpenAI-kompatibles Format.
/// </summary>
private static List<object> BuildApiMessages(string systemPromptText, List<object> messages)
public static List<object> BuildApiMessages(string systemPromptText, List<object> messages)
{
var apiMessages = new List<object>();
if (!string.IsNullOrEmpty(systemPromptText))