SSE Streaming, Tool-Loop Refactor, Hilfsfunktionen extrahiert
This commit is contained in:
1 parent
d32b637760
commit
6ccd8cdb06
2 files changed
+374
-205
No files matched your search
+216
-123
@@ -7,6 +7,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -20,11 +21,12 @@ var app = builder.Build();
|
|||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
||||||
// Workspace-Root für Dateioperationen
|
|
||||||
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
|
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
|
||||||
var workspaceRoot = Path.Combine(dataDir, "workspace");
|
var workspaceRoot = Path.Combine(dataDir, "workspace");
|
||||||
Directory.CreateDirectory(workspaceRoot);
|
Directory.CreateDirectory(workspaceRoot);
|
||||||
|
|
||||||
|
// ── Tool Definitionen ──
|
||||||
|
|
||||||
var toolDefs = new[]
|
var toolDefs = new[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
@@ -88,11 +90,13 @@ var toolDefs = new[]
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ──
|
||||||
|
|
||||||
string ResolvePath(string relativePath)
|
string ResolvePath(string relativePath)
|
||||||
{
|
{
|
||||||
var full = Path.GetFullPath(Path.Combine(workspaceRoot, relativePath ?? ""));
|
var full = Path.GetFullPath(Path.Combine(workspaceRoot, relativePath ?? ""));
|
||||||
if (!full.StartsWith(workspaceRoot))
|
if (!full.StartsWith(workspaceRoot))
|
||||||
throw new System.Exception("Zugriff außerhalb des Workspace nicht erlaubt");
|
throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt");
|
||||||
return full;
|
return full;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,15 +151,45 @@ string ExecuteTool(string name, JsonElement input)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory, IConfiguration config) =>
|
async Task WriteSseEvent(HttpResponse response, string eventType, object data)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(data);
|
||||||
|
await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n");
|
||||||
|
await response.Body.FlushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpClient CreateAnthropicClient(IHttpClientFactory factory, string token)
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||||||
|
client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20");
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)");
|
||||||
|
client.DefaultRequestHeaders.Add("x-app", "cli");
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
client.DefaultRequestHeaders.Add("anthropic-dangerous-direct-browser-access", "true");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE Streaming Endpoint ──
|
||||||
|
|
||||||
|
app.MapPost("/api/chat", async (HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
|
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
|
||||||
if (body == null || body.Messages == null || body.Messages.Count == 0)
|
if (body == null || body.Messages == null || body.Messages.Count == 0)
|
||||||
return Results.BadRequest(new { error = "messages required" });
|
{
|
||||||
|
res.StatusCode = 400;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "messages required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var token = config["AnthropicToken"];
|
var token = config["AnthropicToken"];
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
return Results.Problem("AnthropicToken not configured");
|
{
|
||||||
|
res.StatusCode = 500;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "AnthropicToken not configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var modelId = body.Model switch
|
var modelId = body.Model switch
|
||||||
{
|
{
|
||||||
@@ -164,23 +198,22 @@ app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory,
|
|||||||
_ => "claude-sonnet-4-6"
|
_ => "claude-sonnet-4-6"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Messages in API-Format konvertieren
|
// SSE Response starten
|
||||||
|
res.Headers["Content-Type"] = "text/event-stream";
|
||||||
|
res.Headers["Cache-Control"] = "no-cache";
|
||||||
|
res.Headers["Connection"] = "keep-alive";
|
||||||
|
|
||||||
var messages = new List<object>();
|
var messages = new List<object>();
|
||||||
foreach (var msg in body.Messages)
|
foreach (var msg in body.Messages)
|
||||||
{
|
|
||||||
messages.Add(new { role = msg.Role, content = msg.Content });
|
messages.Add(new { role = msg.Role, content = msg.Content });
|
||||||
}
|
|
||||||
|
|
||||||
var systemPrompt = new List<object>
|
var systemPrompt = new List<object>
|
||||||
{
|
{
|
||||||
new { type = "text", text = "You are Claude Code, Anthropic's official CLI for Claude." }
|
new { type = "text", text = "You are Claude Code, Anthropic's official CLI for Claude." }
|
||||||
};
|
};
|
||||||
if (!string.IsNullOrWhiteSpace(body.SystemPrompt))
|
if (!string.IsNullOrWhiteSpace(body.SystemPrompt))
|
||||||
{
|
|
||||||
systemPrompt.Add(new { type = "text", text = body.SystemPrompt });
|
systemPrompt.Add(new { type = "text", text = body.SystemPrompt });
|
||||||
}
|
|
||||||
|
|
||||||
var steps = new List<StepInfo>();
|
|
||||||
long totalInputTokens = 0;
|
long totalInputTokens = 0;
|
||||||
long totalOutputTokens = 0;
|
long totalOutputTokens = 0;
|
||||||
int iterations = 0;
|
int iterations = 0;
|
||||||
@@ -196,138 +229,219 @@ app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory,
|
|||||||
["max_tokens"] = 4096,
|
["max_tokens"] = 4096,
|
||||||
["system"] = systemPrompt,
|
["system"] = systemPrompt,
|
||||||
["messages"] = messages,
|
["messages"] = messages,
|
||||||
["tools"] = toolDefs
|
["tools"] = toolDefs,
|
||||||
|
["stream"] = true
|
||||||
};
|
};
|
||||||
|
|
||||||
var client = httpFactory.CreateClient();
|
var client = CreateAnthropicClient(httpFactory, token);
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
||||||
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
|
||||||
client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20");
|
|
||||||
client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)");
|
|
||||||
client.DefaultRequestHeaders.Add("x-app", "cli");
|
|
||||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
||||||
client.DefaultRequestHeaders.Add("anthropic-dangerous-direct-browser-access", "true");
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(payload);
|
var json = JsonSerializer.Serialize(payload);
|
||||||
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
|
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
var resp = await client.PostAsync("https://api.anthropic.com/v1/messages", httpContent);
|
var httpResp = await client.SendAsync(
|
||||||
var respBody = await resp.Content.ReadAsStringAsync();
|
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
|
||||||
|
HttpCompletionOption.ResponseHeadersRead
|
||||||
|
);
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!httpResp.IsSuccessStatusCode)
|
||||||
return Results.Problem($"Anthropic API error: {(int)resp.StatusCode} - {respBody}");
|
|
||||||
|
|
||||||
var result = JsonSerializer.Deserialize<JsonElement>(respBody);
|
|
||||||
|
|
||||||
// Usage tracken
|
|
||||||
if (result.TryGetProperty("usage", out var usage))
|
|
||||||
{
|
{
|
||||||
if (usage.TryGetProperty("input_tokens", out var it)) totalInputTokens += it.GetInt64();
|
var errBody = await httpResp.Content.ReadAsStringAsync();
|
||||||
if (usage.TryGetProperty("output_tokens", out var ot)) totalOutputTokens += ot.GetInt64();
|
await WriteSseEvent(res, "error", new { error = $"API {(int)httpResp.StatusCode}: {errBody}" });
|
||||||
|
await WriteSseEvent(res, "done", new { });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var stopReason = result.GetProperty("stop_reason").GetString();
|
// Anthropic SSE Stream parsen
|
||||||
var contentBlocks = result.GetProperty("content");
|
var stream = await httpResp.Content.ReadAsStreamAsync();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
// Assistant-Antwort zur History hinzufügen
|
var stopReason = "";
|
||||||
var assistantContent = new List<object>();
|
var assistantContent = new List<object>();
|
||||||
var textParts = new List<string>();
|
var currentToolId = "";
|
||||||
|
var currentToolName = "";
|
||||||
|
var currentToolInputJson = new StringBuilder();
|
||||||
|
var currentTextBlock = new StringBuilder();
|
||||||
|
var blockIndex = -1;
|
||||||
|
|
||||||
foreach (var block in contentBlocks.EnumerateArray())
|
var blockTypes = new Dictionary<int, string>();
|
||||||
|
var executedTools = new List<(string Id, string Result, bool IsError)>();
|
||||||
|
|
||||||
|
string line;
|
||||||
|
while ((line = await reader.ReadLineAsync()) != null)
|
||||||
{
|
{
|
||||||
var blockType = block.GetProperty("type").GetString();
|
if (!line.StartsWith("data: ")) continue;
|
||||||
if (blockType == "text")
|
|
||||||
{
|
|
||||||
var text = block.GetProperty("text").GetString() ?? "";
|
|
||||||
textParts.Add(text);
|
|
||||||
assistantContent.Add(new { type = "text", text });
|
|
||||||
}
|
|
||||||
else if (blockType == "tool_use")
|
|
||||||
{
|
|
||||||
var toolId = block.GetProperty("id").GetString() ?? "";
|
|
||||||
var toolName = block.GetProperty("name").GetString() ?? "";
|
|
||||||
var toolInput = block.GetProperty("input");
|
|
||||||
|
|
||||||
assistantContent.Add(new
|
var data = line["data: ".Length..];
|
||||||
|
if (string.IsNullOrWhiteSpace(data)) continue;
|
||||||
|
|
||||||
|
JsonElement evt;
|
||||||
|
try { evt = JsonSerializer.Deserialize<JsonElement>(data); }
|
||||||
|
catch { continue; }
|
||||||
|
|
||||||
|
var evtType = evt.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
switch (evtType)
|
||||||
|
{
|
||||||
|
case "message_start":
|
||||||
{
|
{
|
||||||
type = "tool_use",
|
if (evt.TryGetProperty("message", out var msg) &&
|
||||||
id = toolId,
|
msg.TryGetProperty("usage", out var u))
|
||||||
name = toolName,
|
{
|
||||||
input = toolInput
|
if (u.TryGetProperty("input_tokens", out var it)) totalInputTokens += it.GetInt64();
|
||||||
});
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_start":
|
||||||
|
{
|
||||||
|
var idx = evt.GetProperty("index").GetInt32();
|
||||||
|
var cb = evt.GetProperty("content_block");
|
||||||
|
var cbType = cb.GetProperty("type").GetString() ?? "";
|
||||||
|
blockTypes[idx] = cbType;
|
||||||
|
|
||||||
|
if (cbType == "text")
|
||||||
|
{
|
||||||
|
blockIndex = idx;
|
||||||
|
currentTextBlock.Clear();
|
||||||
|
await WriteSseEvent(res, "text_start", new { index = idx });
|
||||||
|
}
|
||||||
|
else if (cbType == "tool_use")
|
||||||
|
{
|
||||||
|
// Vorherigen Text-Block abschließen
|
||||||
|
if (currentTextBlock.Length > 0)
|
||||||
|
{
|
||||||
|
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
|
||||||
|
currentTextBlock.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
blockIndex = idx;
|
||||||
|
currentToolId = cb.GetProperty("id").GetString() ?? "";
|
||||||
|
currentToolName = cb.GetProperty("name").GetString() ?? "";
|
||||||
|
currentToolInputJson.Clear();
|
||||||
|
await WriteSseEvent(res, "tool_start", new { name = currentToolName, id = currentToolId });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_delta":
|
||||||
|
{
|
||||||
|
var idx = evt.GetProperty("index").GetInt32();
|
||||||
|
var delta = evt.GetProperty("delta");
|
||||||
|
var deltaType = delta.GetProperty("type").GetString() ?? "";
|
||||||
|
|
||||||
|
if (deltaType == "text_delta")
|
||||||
|
{
|
||||||
|
var text = delta.GetProperty("text").GetString() ?? "";
|
||||||
|
currentTextBlock.Append(text);
|
||||||
|
await WriteSseEvent(res, "text_delta", new { text });
|
||||||
|
}
|
||||||
|
else if (deltaType == "input_json_delta")
|
||||||
|
{
|
||||||
|
var partial = delta.GetProperty("partial_json").GetString() ?? "";
|
||||||
|
currentToolInputJson.Append(partial);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_stop":
|
||||||
|
{
|
||||||
|
var idx = evt.GetProperty("index").GetInt32();
|
||||||
|
if (blockTypes.TryGetValue(idx, out var bt))
|
||||||
|
{
|
||||||
|
if (bt == "text")
|
||||||
|
{
|
||||||
|
var text = currentTextBlock.ToString();
|
||||||
|
if (text.Length > 0)
|
||||||
|
{
|
||||||
|
assistantContent.Add(new { type = "text", text });
|
||||||
|
await WriteSseEvent(res, "text_end", new { });
|
||||||
|
}
|
||||||
|
currentTextBlock.Clear();
|
||||||
|
}
|
||||||
|
else if (bt == "tool_use")
|
||||||
|
{
|
||||||
|
JsonElement inputEl;
|
||||||
|
try { inputEl = JsonSerializer.Deserialize<JsonElement>(currentToolInputJson.ToString()); }
|
||||||
|
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||||||
|
|
||||||
|
assistantContent.Add(new
|
||||||
|
{
|
||||||
|
type = "tool_use",
|
||||||
|
id = currentToolId,
|
||||||
|
name = currentToolName,
|
||||||
|
input = inputEl
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool sofort ausführen und streamen
|
||||||
|
string toolResult;
|
||||||
|
bool isError = false;
|
||||||
|
try { toolResult = ExecuteTool(currentToolName, inputEl); }
|
||||||
|
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||||||
|
|
||||||
|
executedTools.Add((currentToolId, toolResult, isError));
|
||||||
|
|
||||||
|
await WriteSseEvent(res, "tool_end", new
|
||||||
|
{
|
||||||
|
name = currentToolName,
|
||||||
|
input = currentToolInputJson.ToString(),
|
||||||
|
result = toolResult.Length > 500 ? toolResult[..500] + "..." : toolResult,
|
||||||
|
isError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_delta":
|
||||||
|
{
|
||||||
|
if (evt.TryGetProperty("delta", out var d) &&
|
||||||
|
d.TryGetProperty("stop_reason", out var sr))
|
||||||
|
stopReason = sr.GetString() ?? "";
|
||||||
|
if (evt.TryGetProperty("usage", out var u2) &&
|
||||||
|
u2.TryGetProperty("output_tokens", out var ot))
|
||||||
|
totalOutputTokens += ot.GetInt64();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.Add(new { role = "assistant", content = assistantContent });
|
// Restlichen Text abschließen
|
||||||
|
if (currentTextBlock.Length > 0)
|
||||||
|
assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() });
|
||||||
|
|
||||||
// Zwischen-Text als Step erfassen
|
// Assistant-Antwort zur History
|
||||||
if (textParts.Count > 0)
|
messages.Add(new { role = "assistant", content = assistantContent });
|
||||||
{
|
|
||||||
steps.Add(new StepInfo
|
|
||||||
{
|
|
||||||
Type = "text",
|
|
||||||
Text = string.Join("\n", textParts)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kein Tool Use? Fertig!
|
// Kein Tool Use? Fertig!
|
||||||
if (stopReason != "tool_use")
|
if (stopReason != "tool_use")
|
||||||
{
|
{
|
||||||
return Results.Ok(new
|
await WriteSseEvent(res, "done", new
|
||||||
{
|
{
|
||||||
text = string.Join("\n", textParts),
|
|
||||||
model = modelId,
|
model = modelId,
|
||||||
inputTokens = totalInputTokens,
|
inputTokens = totalInputTokens,
|
||||||
outputTokens = totalOutputTokens,
|
outputTokens = totalOutputTokens,
|
||||||
iterations,
|
iterations
|
||||||
steps
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool Calls ausführen
|
// Tool Results zusammenbauen (wurden beim Streaming bereits ausgeführt)
|
||||||
var toolResults = new List<object>();
|
var toolResults = new List<object>();
|
||||||
foreach (var block in contentBlocks.EnumerateArray())
|
foreach (var tr in executedTools)
|
||||||
{
|
{
|
||||||
if (block.GetProperty("type").GetString() != "tool_use") continue;
|
|
||||||
|
|
||||||
var toolId = block.GetProperty("id").GetString() ?? "";
|
|
||||||
var toolName = block.GetProperty("name").GetString() ?? "";
|
|
||||||
var toolInput = block.GetProperty("input");
|
|
||||||
|
|
||||||
string toolResult;
|
|
||||||
bool isError = false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
toolResult = ExecuteTool(toolName, toolInput);
|
|
||||||
}
|
|
||||||
catch (System.Exception ex)
|
|
||||||
{
|
|
||||||
toolResult = $"Fehler: {ex.Message}";
|
|
||||||
isError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
steps.Add(new StepInfo
|
|
||||||
{
|
|
||||||
Type = "tool",
|
|
||||||
ToolName = toolName,
|
|
||||||
ToolInput = toolInput.ToString(),
|
|
||||||
ToolResult = toolResult.Length > 500 ? toolResult[..500] + "..." : toolResult,
|
|
||||||
IsError = isError
|
|
||||||
});
|
|
||||||
|
|
||||||
toolResults.Add(new
|
toolResults.Add(new
|
||||||
{
|
{
|
||||||
type = "tool_result",
|
type = "tool_result",
|
||||||
tool_use_id = toolId,
|
tool_use_id = tr.Id,
|
||||||
content = toolResult,
|
content = tr.Result,
|
||||||
is_error = isError
|
is_error = tr.IsError
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.Add(new { role = "user", content = toolResults });
|
messages.Add(new { role = "user", content = toolResults });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Problem("Max iterations erreicht");
|
await WriteSseEvent(res, "error", new { error = "Max iterations erreicht" });
|
||||||
|
await WriteSseEvent(res, "done", new { });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Run("http://0.0.0.0:19000");
|
app.Run("http://0.0.0.0:19000");
|
||||||
@@ -352,24 +466,3 @@ class ChatRequest
|
|||||||
[JsonPropertyName("systemPrompt")]
|
[JsonPropertyName("systemPrompt")]
|
||||||
public string SystemPrompt { get; set; } = "";
|
public string SystemPrompt { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
class StepInfo
|
|
||||||
{
|
|
||||||
[JsonPropertyName("type")]
|
|
||||||
public string Type { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("text")]
|
|
||||||
public string Text { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("toolName")]
|
|
||||||
public string ToolName { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("toolInput")]
|
|
||||||
public string ToolInput { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("toolResult")]
|
|
||||||
public string ToolResult { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("isError")]
|
|
||||||
public bool IsError { get; set; }
|
|
||||||
}
|
|
||||||
+158
-82
@@ -16,23 +16,24 @@ select { font-family: monospace; padding: 6px 10px; border: 1px solid #333; bord
|
|||||||
.msg.user { align-self: flex-end; background: #16213e; border: 1px solid #333; }
|
.msg.user { align-self: flex-end; background: #16213e; border: 1px solid #333; }
|
||||||
.msg.assistant { align-self: flex-start; background: #0f3460; border: 1px solid #1a4a7a; }
|
.msg.assistant { align-self: flex-start; background: #0f3460; border: 1px solid #1a4a7a; }
|
||||||
.msg.system-info { align-self: center; background: #1a1a2e; border: 1px solid #333; color: #888; font-size: 0.85em; text-align: center; max-width: 95%; }
|
.msg.system-info { align-self: center; background: #1a1a2e; border: 1px solid #333; color: #888; font-size: 0.85em; text-align: center; max-width: 95%; }
|
||||||
.tool-calls { margin-top: 8px; padding-top: 8px; border-top: 1px solid #1a4a7a; }
|
.msg .cursor { display: inline-block; width: 2px; height: 1em; background: #7c83ff; animation: blink 0.8s infinite; vertical-align: text-bottom; margin-left: 1px; }
|
||||||
.tool-call { margin: 4px 0; padding: 6px 8px; background: #0a1e3d; border-radius: 4px; font-size: 0.85em; }
|
@keyframes blink { 0%,50% { opacity: 1; } 51%,100% { opacity: 0; } }
|
||||||
|
.tool-box { align-self: center; max-width: 95%; padding: 8px 12px; border-radius: 6px; background: #0a1e3d; border: 1px solid #1a3a5c; font-size: 0.85em; }
|
||||||
.tool-name { color: #7c83ff; font-weight: bold; }
|
.tool-name { color: #7c83ff; font-weight: bold; }
|
||||||
|
.tool-input { color: #888; margin-left: 6px; font-size: 0.85em; }
|
||||||
|
.tool-result { color: #6bff8a; font-size: 0.8em; margin-top: 4px; max-height: 100px; overflow-y: auto; }
|
||||||
.tool-error { color: #ff6b6b; }
|
.tool-error { color: #ff6b6b; }
|
||||||
.tool-input { color: #888; margin-left: 8px; }
|
.tool-spinner { color: #7c83ff; }
|
||||||
.tool-result { color: #6bff8a; font-size: 0.8em; margin-top: 2px; max-height: 100px; overflow-y: auto; }
|
|
||||||
.input-area { padding: 12px 20px; border-top: 1px solid #333; display: flex; gap: 8px; flex-shrink: 0; }
|
.input-area { padding: 12px 20px; border-top: 1px solid #333; display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
.input-area textarea { flex: 1; font-family: monospace; padding: 10px; border: 1px solid #333; border-radius: 4px; background: #0f3460; color: #e0e0e0; resize: none; font-size: 14px; min-height: 60px; max-height: 150px; }
|
.input-area textarea { flex: 1; font-family: monospace; padding: 10px; border: 1px solid #333; border-radius: 4px; background: #0f3460; color: #e0e0e0; resize: none; font-size: 14px; min-height: 60px; max-height: 150px; }
|
||||||
.input-area button { padding: 10px 20px; background: #7c83ff; color: #fff; border: none; border-radius: 4px; font-family: monospace; font-weight: bold; cursor: pointer; align-self: flex-end; }
|
.input-area button { padding: 10px 20px; background: #7c83ff; color: #fff; border: none; border-radius: 4px; font-family: monospace; font-weight: bold; cursor: pointer; align-self: flex-end; }
|
||||||
.input-area button:hover { background: #5a63e0; }
|
.input-area button:hover { background: #5a63e0; }
|
||||||
.input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
|
.input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.spinner { color: #7c83ff; }
|
|
||||||
.clear-btn { padding: 6px 10px; background: #333; color: #aaa; border: none; border-radius: 4px; cursor: pointer; font-family: monospace; font-size: 0.85em; }
|
.clear-btn { padding: 6px 10px; background: #333; color: #aaa; border: none; border-radius: 4px; cursor: pointer; font-family: monospace; font-size: 0.85em; }
|
||||||
.clear-btn:hover { background: #444; }
|
.clear-btn:hover { background: #444; }
|
||||||
.system-toggle { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
.system-toggle { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||||
.system-toggle label { font-size: 0.85em; color: #888; cursor: pointer; }
|
.system-toggle label { font-size: 0.85em; color: #888; cursor: pointer; }
|
||||||
#systemPrompt { display: none; width: 100%; font-family: monospace; padding: 8px; border: 1px solid #333; border-radius: 4px; background: #0a0a1a; color: #e0e0e0; resize: vertical; font-size: 12px; min-height: 40px; margin: 0 20px; }
|
#systemPrompt { display: none; width: calc(100% - 40px); font-family: monospace; padding: 8px; border: 1px solid #333; border-radius: 4px; background: #0a0a1a; color: #e0e0e0; resize: vertical; font-size: 12px; min-height: 40px; margin: 8px 20px 0; }
|
||||||
#systemPrompt.visible { display: block; }
|
#systemPrompt.visible { display: block; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -56,12 +57,14 @@ select { font-family: monospace; padding: 6px 10px; border: 1px solid #333; bord
|
|||||||
<div id="chat"></div>
|
<div id="chat"></div>
|
||||||
|
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<textarea id="input" placeholder="Nachricht eingeben... (Ctrl+Enter zum Senden)" rows="2"></textarea>
|
<textarea id="input" placeholder="Nachricht eingeben... (Ctrl+Enter)" rows="2"></textarea>
|
||||||
<button id="send" onclick="sendMessage()">Senden</button>
|
<button id="send" onclick="sendMessage()">Senden</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const messages = [];
|
const messages = [];
|
||||||
|
let currentAssistantDiv = null;
|
||||||
|
let currentAssistantText = '';
|
||||||
|
|
||||||
function toggleSystem() {
|
function toggleSystem() {
|
||||||
document.getElementById('systemPrompt').classList.toggle('visible', document.getElementById('showSystem').checked);
|
document.getElementById('systemPrompt').classList.toggle('visible', document.getElementById('showSystem').checked);
|
||||||
@@ -73,66 +76,8 @@ function clearChat() {
|
|||||||
document.getElementById('meta').textContent = '';
|
document.getElementById('meta').textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(role, text, toolCalls) {
|
function scrollDown() {
|
||||||
const chat = document.getElementById('chat');
|
const chat = document.getElementById('chat');
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'msg ' + role;
|
|
||||||
div.textContent = text;
|
|
||||||
|
|
||||||
if (toolCalls && toolCalls.length > 0) {
|
|
||||||
const tc = document.createElement('div');
|
|
||||||
tc.className = 'tool-calls';
|
|
||||||
tc.innerHTML = '<div style="color:#888;font-size:0.8em;margin-bottom:4px;">Tool Calls (' + toolCalls.length + '):</div>';
|
|
||||||
for (const call of toolCalls) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.className = 'tool-call';
|
|
||||||
d.innerHTML = '<span class="tool-name">' + esc(call.name) + '</span>'
|
|
||||||
+ '<span class="tool-input">' + esc(call.input) + '</span>'
|
|
||||||
+ '<div class="tool-result ' + (call.isError ? 'tool-error' : '') + '">' + esc(call.result) + '</div>';
|
|
||||||
tc.appendChild(d);
|
|
||||||
}
|
|
||||||
div.appendChild(tc);
|
|
||||||
}
|
|
||||||
|
|
||||||
chat.appendChild(div);
|
|
||||||
chat.scrollTop = chat.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSystemInfo(text) {
|
|
||||||
const chat = document.getElementById('chat');
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'msg system-info';
|
|
||||||
div.textContent = text;
|
|
||||||
chat.appendChild(div);
|
|
||||||
chat.scrollTop = chat.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSteps(steps) {
|
|
||||||
const chat = document.getElementById('chat');
|
|
||||||
// Gruppiere: text-steps und tool-steps zwischen texts zusammenfassen
|
|
||||||
let currentDiv = null;
|
|
||||||
let hasText = false;
|
|
||||||
|
|
||||||
for (const step of steps) {
|
|
||||||
if (step.type === 'text' && step.text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'msg assistant';
|
|
||||||
div.textContent = step.text;
|
|
||||||
chat.appendChild(div);
|
|
||||||
currentDiv = div;
|
|
||||||
hasText = true;
|
|
||||||
} else if (step.type === 'tool') {
|
|
||||||
// Tool-Step als kompaktes Info-Element
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'msg system-info';
|
|
||||||
div.innerHTML = '<span class="tool-name">' + esc(step.toolName) + '</span>'
|
|
||||||
+ ' <span class="tool-input">' + esc(step.toolInput) + '</span>'
|
|
||||||
+ '<div class="tool-result ' + (step.isError ? 'tool-error' : '') + '">' + esc(step.toolResult) + '</div>';
|
|
||||||
chat.appendChild(div);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Falls kein Text-Step am Ende war (nur tools), leeren assistant-msg anzeigen vermeiden
|
|
||||||
chat.scrollTop = chat.scrollHeight;
|
chat.scrollTop = chat.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +87,64 @@ function esc(s) {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startAssistantBubble() {
|
||||||
|
const chat = document.getElementById('chat');
|
||||||
|
currentAssistantDiv = document.createElement('div');
|
||||||
|
currentAssistantDiv.className = 'msg assistant';
|
||||||
|
currentAssistantDiv.innerHTML = '<span class="cursor"></span>';
|
||||||
|
currentAssistantText = '';
|
||||||
|
chat.appendChild(currentAssistantDiv);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToAssistant(text) {
|
||||||
|
if (!currentAssistantDiv) startAssistantBubble();
|
||||||
|
currentAssistantText += text;
|
||||||
|
currentAssistantDiv.textContent = currentAssistantText;
|
||||||
|
// Cursor wieder anfügen
|
||||||
|
const cursor = document.createElement('span');
|
||||||
|
cursor.className = 'cursor';
|
||||||
|
currentAssistantDiv.appendChild(cursor);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endAssistantBubble() {
|
||||||
|
if (currentAssistantDiv) {
|
||||||
|
// Cursor entfernen
|
||||||
|
const cursor = currentAssistantDiv.querySelector('.cursor');
|
||||||
|
if (cursor) cursor.remove();
|
||||||
|
// Leere Bubbles entfernen
|
||||||
|
if (!currentAssistantText.trim()) {
|
||||||
|
currentAssistantDiv.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentAssistantDiv = null;
|
||||||
|
currentAssistantText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToolBox(name, status) {
|
||||||
|
const chat = document.getElementById('chat');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'tool-box';
|
||||||
|
div.id = 'tool-' + Date.now();
|
||||||
|
div.innerHTML = '<span class="tool-name">' + esc(name) + '</span> <span class="tool-spinner">⏳</span>';
|
||||||
|
chat.appendChild(div);
|
||||||
|
scrollDown();
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishToolBox(div, data) {
|
||||||
|
if (!div) return;
|
||||||
|
div.innerHTML = '<span class="tool-name">' + esc(data.name) + '</span>'
|
||||||
|
+ '<span class="tool-input">' + esc(truncate(data.input, 120)) + '</span>'
|
||||||
|
+ '<div class="tool-result ' + (data.isError ? 'tool-error' : '') + '">' + esc(truncate(data.result, 300)) + '</div>';
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s, max) {
|
||||||
|
return s && s.length > max ? s.substring(0, max) + '...' : (s || '');
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const input = document.getElementById('input');
|
const input = document.getElementById('input');
|
||||||
const btn = document.getElementById('send');
|
const btn = document.getElementById('send');
|
||||||
@@ -152,11 +155,21 @@ async function sendMessage() {
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
messages.push({ role: 'user', content: text });
|
messages.push({ role: 'user', content: text });
|
||||||
addMessage('user', text);
|
|
||||||
input.value = '';
|
|
||||||
|
|
||||||
|
// User-Bubble
|
||||||
|
const chat = document.getElementById('chat');
|
||||||
|
const userDiv = document.createElement('div');
|
||||||
|
userDiv.className = 'msg user';
|
||||||
|
userDiv.textContent = text;
|
||||||
|
chat.appendChild(userDiv);
|
||||||
|
scrollDown();
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
meta.innerHTML = '<span class="spinner">⏳ Warte auf Antwort...</span>';
|
meta.innerHTML = '<span style="color:#7c83ff">⏳ Streaming...</span>';
|
||||||
|
|
||||||
|
let fullText = '';
|
||||||
|
let currentToolBox = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/chat', {
|
const resp = await fetch('/api/chat', {
|
||||||
@@ -164,23 +177,87 @@ async function sendMessage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ model, messages, systemPrompt })
|
body: JSON.stringify({ model, messages, systemPrompt })
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
addSystemInfo('Fehler: ' + (data.detail || JSON.stringify(data)));
|
|
||||||
} else {
|
|
||||||
messages.push({ role: 'assistant', content: data.text });
|
|
||||||
renderSteps(data.steps || []);
|
|
||||||
|
|
||||||
const toolCount = (data.steps || []).filter(s => s.type === 'tool').length;
|
const reader = resp.body.getReader();
|
||||||
let metaText = `Model: ${data.model} | In: ${data.inputTokens} | Out: ${data.outputTokens}`;
|
const decoder = new TextDecoder();
|
||||||
if (data.iterations > 1) metaText += ` | Iterations: ${data.iterations}`;
|
let buffer = '';
|
||||||
if (toolCount > 0) metaText += ` | Tools: ${toolCount}`;
|
|
||||||
meta.textContent = metaText;
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
var eventType = line.substring(7).trim();
|
||||||
|
} else if (line.startsWith('data: ') && eventType) {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(line.substring(6)); } catch { continue; }
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 'text_start':
|
||||||
|
endAssistantBubble();
|
||||||
|
startAssistantBubble();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'text_delta':
|
||||||
|
appendToAssistant(data.text);
|
||||||
|
fullText += data.text;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'text_end':
|
||||||
|
endAssistantBubble();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_start':
|
||||||
|
endAssistantBubble();
|
||||||
|
currentToolBox = addToolBox(data.name);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_end':
|
||||||
|
finishToolBox(currentToolBox, data);
|
||||||
|
currentToolBox = null;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
endAssistantBubble();
|
||||||
|
const errDiv = document.createElement('div');
|
||||||
|
errDiv.className = 'msg system-info';
|
||||||
|
errDiv.style.color = '#ff6b6b';
|
||||||
|
errDiv.textContent = data.error;
|
||||||
|
chat.appendChild(errDiv);
|
||||||
|
scrollDown();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'done':
|
||||||
|
endAssistantBubble();
|
||||||
|
let metaText = `Model: ${data.model || model} | In: ${data.inputTokens || 0} | Out: ${data.outputTokens || 0}`;
|
||||||
|
if (data.iterations > 1) metaText += ` | Iterations: ${data.iterations}`;
|
||||||
|
meta.textContent = metaText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
eventType = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assistent-Text zur History
|
||||||
|
if (fullText) {
|
||||||
|
messages.push({ role: 'assistant', content: fullText });
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addSystemInfo('Netzwerk-Fehler: ' + e.message);
|
endAssistantBubble();
|
||||||
|
const errDiv = document.createElement('div');
|
||||||
|
errDiv.className = 'msg system-info';
|
||||||
|
errDiv.textContent = 'Netzwerk-Fehler: ' + e.message;
|
||||||
|
chat.appendChild(errDiv);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
endAssistantBubble();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +265,6 @@ document.getElementById('input').addEventListener('keydown', e => {
|
|||||||
if (e.ctrlKey && e.key === 'Enter') sendMessage();
|
if (e.ctrlKey && e.key === 'Enter') sendMessage();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
document.getElementById('input').addEventListener('input', function() {
|
document.getElementById('input').addEventListener('input', function() {
|
||||||
this.style.height = 'auto';
|
this.style.height = 'auto';
|
||||||
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
|
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
|
||||||
|
|||||||
Reference in new issue
Block a user