Add live steering injection into running chains
Let the user inject a message into a running chain instead of only being
able to abort it. While a chain streams, typing + Enter sends the text to
a new /api/chat/steer endpoint; it is drained at the next iteration
boundary, wrapped with a hint so the model actively classifies it
("complements the task -> keep working" vs. "changes course -> adjust"),
and stored as a Type="steer" user message so it survives reload.
Shared infrastructure (provider-agnostic):
- SteeringQueue: out-of-band queue keyed by sessionId, mirrored from
StreamingTracker (Enqueue/Drain/Clear).
- Program.cs registers it as a singleton and passes it to AnthropicService.
- ChatEndpoint: POST /api/chat/steer enqueues only while IsStreaming,
Clear() in the finally.
- ChatService.BuildHistoryFromDb groups Type="steer" so no two
consecutive user turns are produced on re-chat.
- Frontend api.js/chat.js/bubbles.js/history.js + chat.css render the
optimistic steer bubble and the steer_injected SSE event; Enter during
a stream sends a steer instead of being blocked.
Anthropic API path:
- Drain point B (tool boundary, merged into the tool_result user turn)
and C (turn end -> inject + continue instead of finalize).
Claude CLI path (kill-and-restart):
- The agentic loop runs inside the claude child, so true mid-turn inject
is impossible. On a pending steer the child is killed at the next tool
boundary, progress is rebuilt from the DB as a <work_in_progress>
transcript, and the CLI is restarted with the steer appended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a58f279eaa
commit
f2fd493e5f
12 files changed
+555
-132
No files matched your search
@@ -19,9 +19,10 @@ public static class ChatEndpoint
|
|||||||
{
|
{
|
||||||
app.MapPost("/api/chat", HandleChat);
|
app.MapPost("/api/chat", HandleChat);
|
||||||
app.MapPost("/api/chat/stop", HandleStop);
|
app.MapPost("/api/chat/stop", HandleStop);
|
||||||
|
app.MapPost("/api/chat/steer", HandleSteer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task HandleChat(HttpRequest req, HttpResponse res, ServerSettingsService settings, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, OpenAiService openAi, RemoteConnectionManager remoteMgr, EmbeddingBackgroundService embeddingBg, CancellationToken cancellationToken)
|
private static async Task HandleChat(HttpRequest req, HttpResponse res, ServerSettingsService settings, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, SteeringQueue steeringQueue, AnthropicService anthropic, OpenAiService openAi, RemoteConnectionManager remoteMgr, EmbeddingBackgroundService embeddingBg, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
|
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
|
||||||
if (body == null)
|
if (body == null)
|
||||||
@@ -277,6 +278,9 @@ public static class ChatEndpoint
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
streamingTracker.Stop(sessionId);
|
streamingTracker.Stop(sessionId);
|
||||||
|
// Nach Stop ist IsStreaming(sessionId) false → /api/chat/steer enqueued nichts mehr.
|
||||||
|
// Etwaige nicht gedrainte Einwürfe verwerfen (kein Leak in die nächste Chain).
|
||||||
|
steeringQueue.Clear(sessionId);
|
||||||
// Heartbeat-Task beenden (sseLinkCts wird disposed → Token cancelled)
|
// Heartbeat-Task beenden (sseLinkCts wird disposed → Token cancelled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,4 +301,36 @@ public static class ChatEndpoint
|
|||||||
var stopLevel = streamingTracker.Cancel(sessionId);
|
var stopLevel = streamingTracker.Cancel(sessionId);
|
||||||
await res.WriteAsJsonAsync(new { stopped = stopLevel != null, level = stopLevel ?? "none" });
|
await res.WriteAsJsonAsync(new { stopped = stopLevel != null, level = stopLevel ?? "none" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Steering": Reiht eine Nachricht out-of-band in die laufende Chain ein.
|
||||||
|
/// Sie wird an der nächsten Iterations-Grenze als User-Message injiziert.
|
||||||
|
/// Anthropic-API-Pfad: Injektion zwischen den messages.create-Calls.
|
||||||
|
/// Claude-CLI-Pfad: Kill-and-Restart am nächsten Tool-Boundary (der Agentic-Loop
|
||||||
|
/// läuft im claude-Child, daher kein echtes Mid-Turn-Inject möglich).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task HandleSteer(HttpRequest req, HttpResponse res, StreamingTracker streamingTracker, SteeringQueue steeringQueue)
|
||||||
|
{
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(req.Body);
|
||||||
|
var sessionId = body.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
|
||||||
|
var text = body.TryGetProperty("text", out var txt) ? txt.GetString() : null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(sessionId) || string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
res.StatusCode = 400;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "sessionId and text required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur einreihen wenn tatsächlich gerade eine Chain läuft – sonst würde der
|
||||||
|
// Einwurf nie gedraint (kein laufender Loop) und bis zur nächsten Chain liegen bleiben.
|
||||||
|
if (!streamingTracker.IsStreaming(sessionId))
|
||||||
|
{
|
||||||
|
await res.WriteAsJsonAsync(new { injected = false, reason = "not_streaming" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
steeringQueue.Enqueue(sessionId, text.Trim());
|
||||||
|
await res.WriteAsJsonAsync(new { injected = true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
-1
@@ -52,6 +52,8 @@ builder.Services.AddSingleton(authService);
|
|||||||
builder.Services.AddSingleton(dbService);
|
builder.Services.AddSingleton(dbService);
|
||||||
builder.Services.AddSingleton(new ImageService());
|
builder.Services.AddSingleton(new ImageService());
|
||||||
builder.Services.AddSingleton(new StreamingTracker());
|
builder.Services.AddSingleton(new StreamingTracker());
|
||||||
|
var steeringQueue = new SteeringQueue();
|
||||||
|
builder.Services.AddSingleton(steeringQueue);
|
||||||
builder.Services.AddSingleton(new RemoteConnectionManager());
|
builder.Services.AddSingleton(new RemoteConnectionManager());
|
||||||
var memoryService = new MemoryService(dataDir);
|
var memoryService = new MemoryService(dataDir);
|
||||||
var skillService = new SkillService(dataDir);
|
var skillService = new SkillService(dataDir);
|
||||||
@@ -88,7 +90,8 @@ builder.Services.AddSingleton<AnthropicService>(sp =>
|
|||||||
toolService, dbService, memoryService,
|
toolService, dbService, memoryService,
|
||||||
serverSettings,
|
serverSettings,
|
||||||
vectorSearch: vectorSearchService,
|
vectorSearch: vectorSearchService,
|
||||||
embeddingBg: embeddingBgService);
|
embeddingBg: embeddingBgService,
|
||||||
|
steeringQueue: steeringQueue);
|
||||||
svc.SetChatService(chatService);
|
svc.SetChatService(chatService);
|
||||||
return svc;
|
return svc;
|
||||||
});
|
});
|
||||||
|
|||||||
+270
-127
@@ -54,6 +54,7 @@ public partial class AnthropicService
|
|||||||
var tempImagePaths = new List<string>();
|
var tempImagePaths = new List<string>();
|
||||||
string mcpConfigPath = null;
|
string mcpConfigPath = null;
|
||||||
bool isUserChoice = false;
|
bool isUserChoice = false;
|
||||||
|
CancellationTokenSource segCts = null; // aktuelles CLI-Segment (Kill-and-Restart-Steering)
|
||||||
|
|
||||||
var chain = new ChainRecord
|
var chain = new ChainRecord
|
||||||
{
|
{
|
||||||
@@ -133,130 +134,238 @@ public partial class AnthropicService
|
|||||||
|
|
||||||
string choiceToolCallId = null;
|
string choiceToolCallId = null;
|
||||||
|
|
||||||
var result = await _cliService.ChatStreamingAsync(
|
// ── Kill-and-Restart-Steering ──
|
||||||
prompt,
|
// Der Agentic-Loop läuft INNERHALB von `claude -p` – gott sieht nur Events,
|
||||||
async evt =>
|
// echtes Mid-Turn-Inject ist über die CLI nicht möglich (Einwürfe würden nur
|
||||||
|
// gequeued). Stattdessen: kommt während eines Laufs ein Einwurf rein, killen wir
|
||||||
|
// den Child am nächsten Tool-Boundary, hängen den bisherigen Verlauf + den Einwurf
|
||||||
|
// an den Prompt und starten die CLI neu (Abbruch-Semantik – mehr geht über die CLI nicht).
|
||||||
|
var basePrompt = prompt;
|
||||||
|
var currentPrompt = prompt;
|
||||||
|
int totalTurns = 0;
|
||||||
|
double totalCost = 0;
|
||||||
|
int steerRestartCount = 0;
|
||||||
|
CliChatResult result = null;
|
||||||
|
bool steerRestart;
|
||||||
|
bool segSteerTriggered = false;
|
||||||
|
|
||||||
|
// Event-Handler als lokale Funktion (damit der Restart-Loop flach bleibt)
|
||||||
|
async Task HandleCliEvent(CliStreamEvent evt)
|
||||||
|
{
|
||||||
|
switch (evt.Type)
|
||||||
{
|
{
|
||||||
switch (evt.Type)
|
case CliEventType.TextStart:
|
||||||
|
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
|
||||||
|
break;
|
||||||
|
case CliEventType.TextDelta:
|
||||||
|
fullText.Append(evt.Text);
|
||||||
|
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
|
||||||
|
break;
|
||||||
|
case CliEventType.TextEnd:
|
||||||
|
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
|
||||||
|
break;
|
||||||
|
case CliEventType.ToolStart:
|
||||||
{
|
{
|
||||||
case CliEventType.TextStart:
|
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
|
||||||
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
|
if (fullText.Length > 0)
|
||||||
break;
|
|
||||||
case CliEventType.TextDelta:
|
|
||||||
fullText.Append(evt.Text);
|
|
||||||
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
|
|
||||||
break;
|
|
||||||
case CliEventType.TextEnd:
|
|
||||||
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
|
|
||||||
break;
|
|
||||||
case CliEventType.ToolStart:
|
|
||||||
{
|
{
|
||||||
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
|
var textForEmbed = fullText.ToString();
|
||||||
if (fullText.Length > 0)
|
var msgId = await _db.SaveMessage(new MessageRecord
|
||||||
{
|
|
||||||
var textForEmbed = fullText.ToString();
|
|
||||||
var msgId = await _db.SaveMessage(new MessageRecord
|
|
||||||
{
|
|
||||||
ChainId = chainId,
|
|
||||||
Iteration = 1,
|
|
||||||
Role = "assistant",
|
|
||||||
Type = "text",
|
|
||||||
Content = textForEmbed
|
|
||||||
});
|
|
||||||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
|
|
||||||
fullText.Clear();
|
|
||||||
}
|
|
||||||
var mappedName = MapCliToolName(evt.ToolName);
|
|
||||||
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case CliEventType.ToolEnd:
|
|
||||||
{
|
|
||||||
var mappedName = MapCliToolName(evt.ToolName);
|
|
||||||
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
|
|
||||||
// Tool-Use in DB speichern (damit Poll die Tools anzeigt)
|
|
||||||
await _db.SaveMessage(new MessageRecord
|
|
||||||
{
|
{
|
||||||
ChainId = chainId,
|
ChainId = chainId,
|
||||||
Iteration = 1,
|
Iteration = 1,
|
||||||
Role = "assistant",
|
Role = "assistant",
|
||||||
Type = "tool_use",
|
Type = "text",
|
||||||
ToolName = mappedName,
|
Content = textForEmbed
|
||||||
ToolCallId = evt.ToolId,
|
|
||||||
Content = mappedInput
|
|
||||||
});
|
});
|
||||||
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
|
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
|
||||||
|
fullText.Clear();
|
||||||
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
|
|
||||||
if (mappedName == "ask_user_choice")
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
|
|
||||||
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
|
||||||
var cChoices = new List<string>();
|
|
||||||
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
|
||||||
foreach (var cOpt in cc.EnumerateArray())
|
|
||||||
cChoices.Add(cOpt.GetString() ?? "");
|
|
||||||
|
|
||||||
// Alte Antwort entfernen (falls vorhanden)
|
|
||||||
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
|
|
||||||
|
|
||||||
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
|
|
||||||
choiceToolCallId = evt.ToolId;
|
|
||||||
await SseHelper.TryWriteSse(response, "user_choice", new
|
|
||||||
{
|
|
||||||
toolCallId = evt.ToolId,
|
|
||||||
question = cQuestion,
|
|
||||||
choices = cChoices,
|
|
||||||
viaCli = true
|
|
||||||
}, sseCt);
|
|
||||||
|
|
||||||
isUserChoice = true;
|
|
||||||
}
|
|
||||||
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case CliEventType.ToolResult:
|
|
||||||
{
|
|
||||||
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
|
|
||||||
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
|
|
||||||
await _db.SaveMessage(new MessageRecord
|
|
||||||
{
|
|
||||||
ChainId = chainId,
|
|
||||||
Iteration = 1,
|
|
||||||
Role = "user",
|
|
||||||
Type = "tool_result",
|
|
||||||
ToolCallId = evt.ToolId,
|
|
||||||
Content = resultContent,
|
|
||||||
IsError = evt.ToolResultIsError
|
|
||||||
});
|
|
||||||
// SSE Event an Client senden
|
|
||||||
await SseHelper.TryWriteSse(response, "tool_result", new
|
|
||||||
{
|
|
||||||
id = evt.ToolId,
|
|
||||||
result = resultContent,
|
|
||||||
isError = evt.ToolResultIsError
|
|
||||||
}, sseCt);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
var mappedName = MapCliToolName(evt.ToolName);
|
||||||
|
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
case CliEventType.ToolEnd:
|
||||||
modelId: modelId,
|
{
|
||||||
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
|
var mappedName = MapCliToolName(evt.ToolName);
|
||||||
workspace: workspace,
|
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
|
||||||
hasWorkspace: !string.IsNullOrEmpty(workspace),
|
// Tool-Use in DB speichern (damit Poll die Tools anzeigt)
|
||||||
planMode: planMode,
|
await _db.SaveMessage(new MessageRecord
|
||||||
webSearch: webSearch,
|
{
|
||||||
mcpConfigPath: mcpConfigPath,
|
ChainId = chainId,
|
||||||
hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl),
|
Iteration = 1,
|
||||||
effort: effort,
|
Role = "assistant",
|
||||||
cancellationToken: workCt
|
Type = "tool_use",
|
||||||
);
|
ToolName = mappedName,
|
||||||
|
ToolCallId = evt.ToolId,
|
||||||
|
Content = mappedInput
|
||||||
|
});
|
||||||
|
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
|
||||||
|
|
||||||
|
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
|
||||||
|
if (mappedName == "ask_user_choice")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
|
||||||
|
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||||
|
var cChoices = new List<string>();
|
||||||
|
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||||||
|
foreach (var cOpt in cc.EnumerateArray())
|
||||||
|
cChoices.Add(cOpt.GetString() ?? "");
|
||||||
|
|
||||||
|
// Alte Antwort entfernen (falls vorhanden)
|
||||||
|
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
|
||||||
|
|
||||||
|
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
|
||||||
|
choiceToolCallId = evt.ToolId;
|
||||||
|
await SseHelper.TryWriteSse(response, "user_choice", new
|
||||||
|
{
|
||||||
|
toolCallId = evt.ToolId,
|
||||||
|
question = cQuestion,
|
||||||
|
choices = cChoices,
|
||||||
|
viaCli = true
|
||||||
|
}, sseCt);
|
||||||
|
|
||||||
|
isUserChoice = true;
|
||||||
|
}
|
||||||
|
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CliEventType.ToolResult:
|
||||||
|
{
|
||||||
|
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
|
||||||
|
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId,
|
||||||
|
Iteration = 1,
|
||||||
|
Role = "user",
|
||||||
|
Type = "tool_result",
|
||||||
|
ToolCallId = evt.ToolId,
|
||||||
|
Content = resultContent,
|
||||||
|
IsError = evt.ToolResultIsError
|
||||||
|
});
|
||||||
|
// SSE Event an Client senden
|
||||||
|
await SseHelper.TryWriteSse(response, "tool_result", new
|
||||||
|
{
|
||||||
|
id = evt.ToolId,
|
||||||
|
result = resultContent,
|
||||||
|
isError = evt.ToolResultIsError
|
||||||
|
}, sseCt);
|
||||||
|
|
||||||
|
// Steering: Tool-Boundary erreicht – steht ein Einwurf an, Child killen
|
||||||
|
// (löst OperationCanceledException aus → Restart unten).
|
||||||
|
if (!segSteerTriggered && !isUserChoice
|
||||||
|
&& _steeringQueue != null && !string.IsNullOrEmpty(sessionId)
|
||||||
|
&& _steeringQueue.HasPending(sessionId))
|
||||||
|
{
|
||||||
|
segSteerTriggered = true;
|
||||||
|
try { segCts?.Cancel(); } catch { }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
steerRestart = false;
|
||||||
|
segSteerTriggered = false;
|
||||||
|
segCts?.Dispose();
|
||||||
|
segCts = CancellationTokenSource.CreateLinkedTokenSource(workCt);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await _cliService.ChatStreamingAsync(
|
||||||
|
currentPrompt,
|
||||||
|
HandleCliEvent,
|
||||||
|
modelId: modelId,
|
||||||
|
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
|
||||||
|
workspace: workspace,
|
||||||
|
hasWorkspace: !string.IsNullOrEmpty(workspace),
|
||||||
|
planMode: planMode,
|
||||||
|
webSearch: webSearch,
|
||||||
|
mcpConfigPath: mcpConfigPath,
|
||||||
|
hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl),
|
||||||
|
effort: effort,
|
||||||
|
cancellationToken: segCts.Token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (segSteerTriggered && !workCt.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Steering-Kill (kein echter Abbruch) → Restart unten vorbereiten
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens des abgeschlossenen Segments akkumulieren (bei Kill: result == null)
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
usage.InputTokens += result.InputTokens;
|
||||||
|
usage.OutputTokens += result.OutputTokens;
|
||||||
|
usage.CacheReadTokens += result.CacheReadTokens;
|
||||||
|
usage.CacheCreationTokens += result.CacheCreationTokens;
|
||||||
|
totalCost += result.CostUsd;
|
||||||
|
totalTurns += result.NumTurns > 0 ? result.NumTurns : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart nötig? (a) Kill am Tool-Boundary, (b) Einwurf kam erst am Turn-Ende rein
|
||||||
|
bool steerPending = !isUserChoice && _steeringQueue != null
|
||||||
|
&& !string.IsNullOrEmpty(sessionId) && _steeringQueue.HasPending(sessionId);
|
||||||
|
if (segSteerTriggered || steerPending)
|
||||||
|
{
|
||||||
|
steerRestart = true;
|
||||||
|
Console.WriteLine($"[CLI-Steering] Restart #{++steerRestartCount} (chain {chainId})");
|
||||||
|
|
||||||
|
// 1. Bisher gepufferten Assistant-Text dieses Segments persistieren
|
||||||
|
if (fullText.Length > 0)
|
||||||
|
{
|
||||||
|
var partial = fullText.ToString();
|
||||||
|
var pid = await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId, Iteration = 1,
|
||||||
|
Role = "assistant", Type = "text", Content = partial
|
||||||
|
});
|
||||||
|
_embeddingBg?.Enqueue(pid, chainId, sessionId, agentId, "assistant", partial);
|
||||||
|
fullText.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verlauf dieses Durchlaufs aus der DB als Transkript (vor dem Persistieren der neuen Einwürfe)
|
||||||
|
var progressLog = await BuildCliProgressLog(chainId);
|
||||||
|
|
||||||
|
// 3. Einwürfe entnehmen, RAW persistieren (Type=steer), via SSE melden, gewrappt sammeln
|
||||||
|
var steers = _steeringQueue.Drain(sessionId);
|
||||||
|
var wrapped = new StringBuilder();
|
||||||
|
foreach (var steer in steers)
|
||||||
|
{
|
||||||
|
var sid = await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId, Iteration = 1,
|
||||||
|
Role = "user", Type = "steer", Content = steer
|
||||||
|
});
|
||||||
|
_embeddingBg?.Enqueue(sid, chainId, sessionId, agentId, "user", steer);
|
||||||
|
await SseHelper.TryWriteSse(response, "steer_injected", new { text = steer }, sseCt);
|
||||||
|
wrapped.AppendLine(WrapSteeringMessage(steer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Restart-Prompt: Basis-Prompt + bisheriger Verlauf + Einwürfe
|
||||||
|
var sb = new StringBuilder(basePrompt);
|
||||||
|
if (!string.IsNullOrEmpty(progressLog))
|
||||||
|
{
|
||||||
|
sb.Append("\n\n<work_in_progress>\nDu arbeitest bereits an obiger Aufgabe. Bisheriger Verlauf in diesem Durchlauf (Text, Tool-Aufrufe, Ergebnisse):\n");
|
||||||
|
sb.Append(progressLog);
|
||||||
|
sb.Append("\n</work_in_progress>");
|
||||||
|
}
|
||||||
|
sb.Append("\n\n");
|
||||||
|
sb.Append(wrapped.ToString().Trim());
|
||||||
|
currentPrompt = sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (steerRestart && !workCt.IsCancellationRequested);
|
||||||
|
|
||||||
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
|
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
|
||||||
if (!result.Success && !isUserChoice)
|
if (result != null && !result.Success && !isUserChoice)
|
||||||
{
|
{
|
||||||
var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert";
|
var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert";
|
||||||
// Error in DB speichern (damit er beim Neuladen sichtbar ist)
|
// Error in DB speichern (damit er beim Neuladen sichtbar ist)
|
||||||
@@ -271,7 +380,7 @@ public partial class AnthropicService
|
|||||||
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
|
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antwort in DB speichern
|
// Antwort in DB speichern (finaler Text des letzten Segments)
|
||||||
if (fullText.Length > 0)
|
if (fullText.Length > 0)
|
||||||
{
|
{
|
||||||
var finalText = fullText.ToString();
|
var finalText = fullText.ToString();
|
||||||
@@ -286,24 +395,19 @@ public partial class AnthropicService
|
|||||||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText);
|
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token-Usage vom CLI-Result übernehmen
|
// Token-Usage wurde segmentweise in usage/totalTurns/totalCost akkumuliert (siehe Restart-Loop)
|
||||||
usage.InputTokens = result.InputTokens;
|
var stopReason = isUserChoice ? "awaiting_user_choice" : (result != null && result.Success ? "end_turn" : "error");
|
||||||
usage.OutputTokens = result.OutputTokens;
|
await FinalizeChain(chain, usage, totalTurns > 0 ? totalTurns : 1, stopReason, sw);
|
||||||
usage.CacheReadTokens = result.CacheReadTokens;
|
|
||||||
usage.CacheCreationTokens = result.CacheCreationTokens;
|
|
||||||
|
|
||||||
var stopReason = isUserChoice ? "awaiting_user_choice" : (result.Success ? "end_turn" : "error");
|
|
||||||
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
|
|
||||||
|
|
||||||
await SseHelper.TryWriteSse(response, "done", new
|
await SseHelper.TryWriteSse(response, "done", new
|
||||||
{
|
{
|
||||||
model = modelId,
|
model = modelId,
|
||||||
inputTokens = result.InputTokens,
|
inputTokens = usage.InputTokens,
|
||||||
outputTokens = result.OutputTokens,
|
outputTokens = usage.OutputTokens,
|
||||||
cacheCreationTokens = result.CacheCreationTokens,
|
cacheCreationTokens = usage.CacheCreationTokens,
|
||||||
cacheReadTokens = result.CacheReadTokens,
|
cacheReadTokens = usage.CacheReadTokens,
|
||||||
costUsd = result.CostUsd,
|
costUsd = totalCost,
|
||||||
iterations = result.NumTurns > 0 ? result.NumTurns : 1,
|
iterations = totalTurns > 0 ? totalTurns : 1,
|
||||||
chainId,
|
chainId,
|
||||||
durationMs = sw.ElapsedMilliseconds,
|
durationMs = sw.ElapsedMilliseconds,
|
||||||
stopReason,
|
stopReason,
|
||||||
@@ -360,6 +464,8 @@ public partial class AnthropicService
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
segCts?.Dispose();
|
||||||
|
|
||||||
// Temp-Bilddateien aufräumen
|
// Temp-Bilddateien aufräumen
|
||||||
CleanupTempImages(tempImagePaths);
|
CleanupTempImages(tempImagePaths);
|
||||||
|
|
||||||
@@ -371,6 +477,43 @@ public partial class AnthropicService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut aus den bereits gespeicherten Messages einer Chain ein kompaktes Verlauf-Transkript
|
||||||
|
/// für den Kill-and-Restart-Steering-Prompt (was hat das Modell in diesem Durchlauf bereits getan).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> BuildCliProgressLog(string chainId)
|
||||||
|
{
|
||||||
|
var msgs = await _db.GetChainMessages(chainId);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var m in msgs)
|
||||||
|
{
|
||||||
|
switch (m.Type)
|
||||||
|
{
|
||||||
|
case "text":
|
||||||
|
if (m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content))
|
||||||
|
sb.AppendLine("[Du]: " + m.Content.Trim());
|
||||||
|
break;
|
||||||
|
case "tool_use":
|
||||||
|
sb.AppendLine($"[Du hast aufgerufen: {m.ToolName}] {TruncateForLog(m.Content, 600)}");
|
||||||
|
break;
|
||||||
|
case "tool_result":
|
||||||
|
sb.AppendLine($"[Ergebnis{(m.IsError ? " (Fehler)" : "")}]: {TruncateForLog(m.Content, 600)}");
|
||||||
|
break;
|
||||||
|
case "steer":
|
||||||
|
sb.AppendLine("[Vorheriger Live-Einwurf des Users]: " + (m.Content ?? "").Trim());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TruncateForLog(string s, int max)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return "";
|
||||||
|
s = s.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||||
|
return s.Length <= max ? s : s.Substring(0, max) + " …[gekürzt]";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Non-Streaming Chat über die Claude CLI für Telegram (und andere Caller, die kein SSE brauchen).
|
/// Non-Streaming Chat über die Claude CLI für Telegram (und andere Caller, die kein SSE brauchen).
|
||||||
/// Sammelt den finalen Text und gibt ihn zurück. Persistiert Text, Tool-Use und Tool-Result in der DB,
|
/// Sammelt den finalen Text und gibt ihn zurück. Persistiert Text, Tool-Use und Tool-Result in der DB,
|
||||||
|
|||||||
@@ -14,6 +14,45 @@ namespace Gott.Services;
|
|||||||
|
|
||||||
public partial class AnthropicService
|
public partial class AnthropicService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verpackt einen Live-Einwurf (Steering) des Users mit einem Hinweis, damit das
|
||||||
|
/// Modell ihn aktiv einordnet statt ihn nur passiv anzuhängen: laufende Aufgabe
|
||||||
|
/// ergänzen ODER Kurs ändern.
|
||||||
|
/// </summary>
|
||||||
|
private static string WrapSteeringMessage(string text)
|
||||||
|
{
|
||||||
|
return "[Live-Einwurf vom User – kam herein, während du an der laufenden Aufgabe arbeitest]:\n"
|
||||||
|
+ text
|
||||||
|
+ "\n\n<hint>Berücksichtige diesen Einwurf ab sofort. Ergänzt er nur die laufende Aufgabe → arbeite weiter und beziehe ihn ein. Ändert er den Kurs → passe dein Vorgehen an und sag in einem Satz, was du jetzt anders machst.</hint>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draint die Steering-Queue der Session und hängt etwaige Live-Einwürfe als
|
||||||
|
/// Text-Blöcke (mit Hinweis-Wrapper) an die übergebene Content-Liste eines
|
||||||
|
/// user-Turns. Persistiert jeden Einwurf RAW (Role=user, Type=steer) für History +
|
||||||
|
/// Re-Chat und meldet ihn via SSE (steer_injected) ans Frontend.
|
||||||
|
/// Gibt die Anzahl injizierter Einwürfe zurück.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> InjectSteeringAsync(HttpResponse response, List<object> content, string chainId, string sessionId, string agentId, int iteration, CancellationToken sseCt)
|
||||||
|
{
|
||||||
|
if (_steeringQueue == null || string.IsNullOrEmpty(sessionId)) return 0;
|
||||||
|
var steers = _steeringQueue.Drain(sessionId);
|
||||||
|
foreach (var steer in steers)
|
||||||
|
{
|
||||||
|
// API: gewrappter Text-Block (mit Hinweis). DB: RAW-Text (sauber für History/Re-Chat).
|
||||||
|
content.Add(new { type = "text", text = WrapSteeringMessage(steer) });
|
||||||
|
var msgId = await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId, Iteration = iteration,
|
||||||
|
Role = "user", Type = "steer",
|
||||||
|
Content = steer
|
||||||
|
});
|
||||||
|
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "user", steer);
|
||||||
|
await SseHelper.TryWriteSse(response, "steer_injected", new { text = steer }, sseCt);
|
||||||
|
}
|
||||||
|
return steers.Count;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task StreamChat(
|
public async Task StreamChat(
|
||||||
HttpResponse response,
|
HttpResponse response,
|
||||||
string modelId,
|
string modelId,
|
||||||
@@ -288,6 +327,23 @@ public partial class AnthropicService
|
|||||||
iterations += finalizerIterations;
|
iterations += finalizerIterations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Steering-Injektion (Drain-Punkt C) ──
|
||||||
|
// Das Modell würde jetzt seinen Turn beenden. Hat der User einen Einwurf
|
||||||
|
// geschickt (während des Turns oder der Review/Finalizer-Phase), injizieren
|
||||||
|
// wir ihn als neuen user-Turn und lassen die Chain WEITERlaufen statt zu
|
||||||
|
// finalisieren. NICHT bei awaiting_user_choice – dort wartet die Chain
|
||||||
|
// bewusst auf die Choice-Antwort des Users.
|
||||||
|
if (stopReason != "awaiting_user_choice")
|
||||||
|
{
|
||||||
|
var steerContent = new List<object>();
|
||||||
|
var injectedCount = await InjectSteeringAsync(response, steerContent, chainId, sessionId, agentId, iterations, sseCt);
|
||||||
|
if (injectedCount > 0)
|
||||||
|
{
|
||||||
|
messages.Add(new { role = "user", content = steerContent });
|
||||||
|
continue; // → Chain läuft weiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
|
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
|
||||||
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
|
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
|
||||||
await SseHelper.TryWriteSse(response, "done", new
|
await SseHelper.TryWriteSse(response, "done", new
|
||||||
@@ -320,6 +376,12 @@ public partial class AnthropicService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Steering-Injektion (Drain-Punkt B) ──
|
||||||
|
// Live-Einwürfe, die während dieser Tool-Runde eingingen, an DENSELBEN
|
||||||
|
// user-Turn anhängen (tool_result-Blöcke zuerst, dann Text – API-konform).
|
||||||
|
// So sieht das Modell den Einwurf in der nächsten Iteration, mitten in der Arbeit.
|
||||||
|
await InjectSteeringAsync(response, toolResults, chainId, sessionId, agentId, iterations, sseCt);
|
||||||
|
|
||||||
messages.Add(new { role = "user", content = toolResults });
|
messages.Add(new { role = "user", content = toolResults });
|
||||||
|
|
||||||
// Hard-Stop Check nach Tool-Ausführung: Sofort raus statt nächste Iteration
|
// Hard-Stop Check nach Tool-Ausführung: Sofort raus statt nächste Iteration
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ public partial class AnthropicService
|
|||||||
private readonly ServerSettingsService _settings;
|
private readonly ServerSettingsService _settings;
|
||||||
private readonly VectorSearchService _vectorSearch;
|
private readonly VectorSearchService _vectorSearch;
|
||||||
private readonly EmbeddingBackgroundService _embeddingBg;
|
private readonly EmbeddingBackgroundService _embeddingBg;
|
||||||
|
private readonly SteeringQueue _steeringQueue;
|
||||||
private ChatService _chatService;
|
private ChatService _chatService;
|
||||||
private TelegramService _telegramService;
|
private TelegramService _telegramService;
|
||||||
private ClaudeCliService _cliService;
|
private ClaudeCliService _cliService;
|
||||||
|
|
||||||
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
|
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, SteeringQueue steeringQueue = null)
|
||||||
{
|
{
|
||||||
_httpFactory = httpFactory;
|
_httpFactory = httpFactory;
|
||||||
_toolService = toolService;
|
_toolService = toolService;
|
||||||
@@ -56,6 +57,7 @@ public partial class AnthropicService
|
|||||||
_settings = settings;
|
_settings = settings;
|
||||||
_vectorSearch = vectorSearch;
|
_vectorSearch = vectorSearch;
|
||||||
_embeddingBg = embeddingBg;
|
_embeddingBg = embeddingBg;
|
||||||
|
_steeringQueue = steeringQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetCliService(ClaudeCliService cliService)
|
public void SetCliService(ClaudeCliService cliService)
|
||||||
|
|||||||
+10
-1
@@ -294,7 +294,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
|
|||||||
{
|
{
|
||||||
foreach (var msg in msgs)
|
foreach (var msg in msgs)
|
||||||
{
|
{
|
||||||
if (msg.Role == "user" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
|
if (msg.Role == "user" && (msg.Type == "text" || msg.Type == "steer") && !string.IsNullOrEmpty(msg.Content))
|
||||||
{
|
{
|
||||||
history.Add(new Dictionary<string, object>
|
history.Add(new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -362,6 +362,15 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
|
|||||||
if (content.Count > 0)
|
if (content.Count > 0)
|
||||||
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = content });
|
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = content });
|
||||||
}
|
}
|
||||||
|
else if (msg.Role == "user" && msg.Type == "steer")
|
||||||
|
{
|
||||||
|
// Live-Einwurf (Steering) des Users während die Chain lief.
|
||||||
|
// AppendToLastMessage mergt ihn in den vorausgehenden user-Turn
|
||||||
|
// (tool_result-Runde bei Drain-B) bzw. erzeugt einen neuen user-Turn
|
||||||
|
// nach dem Assistant (Drain-C) → niemals zwei aufeinanderfolgende user-Turns.
|
||||||
|
if (!string.IsNullOrEmpty(msg.Content))
|
||||||
|
AppendToLastMessage(history, "user", new { type = "text", text = msg.Content });
|
||||||
|
}
|
||||||
else if (msg.Role == "assistant" && msg.Type == "thinking" && includeThinking)
|
else if (msg.Role == "assistant" && msg.Type == "thinking" && includeThinking)
|
||||||
{
|
{
|
||||||
// Thinking-Inhalt für OpenAI-kompatible Provider mitführen.
|
// Thinking-Inhalt für OpenAI-kompatible Provider mitführen.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Gott.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Out-of-band-Kanal für "Steering"-Einwürfe: Nachrichten, die der User schickt
|
||||||
|
/// WÄHREND eine Chain läuft (vgl. /btw bzw. Queued-Steering in Claude Code).
|
||||||
|
/// 1:1 gespiegelt vom <see cref="StreamingTracker"/> – keyed by sessionId.
|
||||||
|
///
|
||||||
|
/// Der Streaming-Loop (AnthropicService.StreamChat) draint die Queue an den
|
||||||
|
/// Iterations-Grenzen (nach einer Tool-Runde bzw. bevor die Chain finalisiert)
|
||||||
|
/// und injiziert die Texte als User-Message in die laufende Konversation.
|
||||||
|
/// Ein laufender messages.create-Request ist unveränderlich – Injektion geht
|
||||||
|
/// nur ZWISCHEN den API-Calls, nicht mitten im Stream.
|
||||||
|
///
|
||||||
|
/// Im CLI-Pfad (AnthropicService.StreamChatViaCli) läuft der Agentic-Loop im
|
||||||
|
/// claude-Child; dort draint ein Kill-and-Restart am nächsten Tool-Boundary
|
||||||
|
/// (Child killen, Verlauf + Einwurf an den Prompt hängen, CLI neu starten).
|
||||||
|
/// </summary>
|
||||||
|
public class SteeringQueue
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, ConcurrentQueue<string>> _queues = new ConcurrentDictionary<string, ConcurrentQueue<string>>();
|
||||||
|
|
||||||
|
/// <summary>Reiht einen Einwurf für eine Session ein (FIFO).</summary>
|
||||||
|
public void Enqueue(string sessionId, string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sessionId) || string.IsNullOrWhiteSpace(text)) return;
|
||||||
|
var q = _queues.GetOrAdd(sessionId, _ => new ConcurrentQueue<string>());
|
||||||
|
q.Enqueue(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entnimmt ALLE pending Einwürfe einer Session (FIFO) und gibt sie zurück.
|
||||||
|
/// Leere Liste wenn nichts ansteht.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Drain(string sessionId)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
if (_queues.TryGetValue(sessionId, out var q))
|
||||||
|
{
|
||||||
|
while (q.TryDequeue(out var text))
|
||||||
|
result.Add(text);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prüft ob für eine Session Einwürfe anstehen.</summary>
|
||||||
|
public bool HasPending(string sessionId)
|
||||||
|
=> _queues.TryGetValue(sessionId, out var q) && !q.IsEmpty;
|
||||||
|
|
||||||
|
/// <summary>Entfernt die Queue einer Session (beim Chain-Ende aufräumen).</summary>
|
||||||
|
public void Clear(string sessionId) => _queues.TryRemove(sessionId, out _);
|
||||||
|
}
|
||||||
@@ -159,6 +159,29 @@
|
|||||||
background: var(--f2-bg);
|
background: var(--f2-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Steering: Live-Einwurf während eine Chain läuft */
|
||||||
|
.msg.user.steer {
|
||||||
|
border-color: var(--warning);
|
||||||
|
border-left-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steer-label {
|
||||||
|
font-size: .72em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--warning);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: .9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steer-label .fa { margin-right: 3px; }
|
||||||
|
|
||||||
|
/* Eingabefeld signalisiert "Einwurf-Modus" solange ein Stream läuft */
|
||||||
|
#input.steer-mode {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.msg.assistant {
|
.msg.assistant {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
background: var(--f1-bg);
|
background: var(--f1-bg);
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ function apiChoiceRespond(sessionId, choice) {
|
|||||||
|
|
||||||
function apiStopChat(sessionId) { return _post('/api/chat/stop', { sessionId: sessionId }); }
|
function apiStopChat(sessionId) { return _post('/api/chat/stop', { sessionId: sessionId }); }
|
||||||
|
|
||||||
|
/** Steering: Nachricht in die LAUFENDE Chain einwerfen (wird an nächster Iterations-Grenze injiziert). */
|
||||||
|
function apiChatSteer(sessionId, text) { return _post('/api/chat/steer', { sessionId: sessionId, text: text }); }
|
||||||
|
|
||||||
// ── Agent API ──
|
// ── Agent API ──
|
||||||
|
|
||||||
function apiGetAgents() { return _get('/api/agents'); }
|
function apiGetAgents() { return _get('/api/agents'); }
|
||||||
|
|||||||
@@ -157,6 +157,27 @@ function addUserBubble(text, streamed = false, timestamp = '', messageId = null,
|
|||||||
scrollDown();
|
scrollDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Steering Bubble (Live-Einwurf während eine Chain läuft) ──
|
||||||
|
|
||||||
|
/** Baut eine Steering-Bubble (User-Stil + Einwurf-Label). Returns den div – für Live + History. */
|
||||||
|
function _buildSteerBubble(opts) {
|
||||||
|
const div = _createMessageBubble({ role: 'user', extraClass: 'user steer', timestamp: opts.timestamp, messageId: opts.messageId, isMarked: opts.isMarked, deletedOn: opts.deletedOn, archived: opts.archived, streamed: opts.streamed });
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'steer-label';
|
||||||
|
label.innerHTML = '<i class="fa fa-bolt"></i> Einwurf';
|
||||||
|
div.appendChild(label);
|
||||||
|
renderUserContent(div, opts.text);
|
||||||
|
_addBubbleActions(div, { timestamp: opts.timestamp, messageId: opts.messageId, deletedOn: opts.deletedOn, isMarked: opts.isMarked });
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSteerBubble(text, streamed = false) {
|
||||||
|
const div = _buildSteerBubble({ text, streamed });
|
||||||
|
chatEl().appendChild(div);
|
||||||
|
scrollDown();
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
function _addSystemBubble(text, icon, color, streamed) {
|
function _addSystemBubble(text, icon, color, streamed) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'msg system-info';
|
div.className = 'msg system-info';
|
||||||
|
|||||||
+54
-1
@@ -136,6 +136,12 @@ function handleStreamEvent(eventType, data) {
|
|||||||
addWarningBubble(`Auto-Continue (${data.attempt}/${data.maxAttempts}): Modell wird fortgesetzt…`, true);
|
addWarningBubble(`Auto-Continue (${data.attempt}/${data.maxAttempts}): Modell wird fortgesetzt…`, true);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'steer_injected':
|
||||||
|
// Das Modell hat den Einwurf an dieser Stelle erhalten. Die Anzeige übernimmt
|
||||||
|
// bereits die optimistische Bubble aus sendSteer(); am Stream-Ende ersetzt
|
||||||
|
// pollHistory() sie durch die persistierte DB-Version (Type=steer).
|
||||||
|
break;
|
||||||
|
|
||||||
case 'warning':
|
case 'warning':
|
||||||
case 'info':
|
case 'info':
|
||||||
addWarningBubble(data.message, true);
|
addWarningBubble(data.message, true);
|
||||||
@@ -278,6 +284,14 @@ async function _runStream(fetchPromise) {
|
|||||||
_stopClickCount = 0;
|
_stopClickCount = 0;
|
||||||
startTypingIndicator();
|
startTypingIndicator();
|
||||||
|
|
||||||
|
// Während des Streams wird die Eingabe zum Steering-Einwurf (Enter wirft ein).
|
||||||
|
const _steerInputEl = $('#input');
|
||||||
|
const _prevPlaceholder = _steerInputEl ? _steerInputEl.placeholder : '';
|
||||||
|
if (_steerInputEl) {
|
||||||
|
_steerInputEl.placeholder = '💬 Einwurf an die laufende Arbeit… (Enter)';
|
||||||
|
_steerInputEl.classList.add('steer-mode');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchPromise(_abortController.signal);
|
const resp = await fetchPromise(_abortController.signal);
|
||||||
const reader = resp.body.getReader();
|
const reader = resp.body.getReader();
|
||||||
@@ -331,6 +345,11 @@ async function _runStream(fetchPromise) {
|
|||||||
stopBtn.title = 'Antwort abbrechen';
|
stopBtn.title = 'Antwort abbrechen';
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
_abortController = null;
|
_abortController = null;
|
||||||
|
// Steering-Hinweis zurücksetzen (Eingabe ist wieder normales Senden)
|
||||||
|
if (_steerInputEl) {
|
||||||
|
_steerInputEl.placeholder = _prevPlaceholder;
|
||||||
|
_steerInputEl.classList.remove('steer-mode');
|
||||||
|
}
|
||||||
// Meta zurücksetzen falls kein done-Event kam (z.B. nach Stop)
|
// Meta zurücksetzen falls kein done-Event kam (z.B. nach Stop)
|
||||||
var metaEl = $('#meta');
|
var metaEl = $('#meta');
|
||||||
if (metaEl && (metaEl.textContent.includes('Streaming') || metaEl.textContent.includes('...'))) {
|
if (metaEl && (metaEl.textContent.includes('Streaming') || metaEl.textContent.includes('...'))) {
|
||||||
@@ -350,8 +369,14 @@ async function _runStream(fetchPromise) {
|
|||||||
// ── Send Message ──
|
// ── Send Message ──
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
if (isStreaming) return;
|
|
||||||
const input = $('#input');
|
const input = $('#input');
|
||||||
|
if (isStreaming) {
|
||||||
|
// Steering: Während eine Chain läuft, wird die Nachricht als Einwurf in die
|
||||||
|
// laufende Arbeit eingespeist (statt wie früher hart blockiert zu werden).
|
||||||
|
const steerText = input.value.trim();
|
||||||
|
if (steerText) sendSteer(steerText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text && pendingFiles.length === 0 && pendingRefs.length === 0) return;
|
if (!text && pendingFiles.length === 0 && pendingRefs.length === 0) return;
|
||||||
|
|
||||||
@@ -424,6 +449,34 @@ async function sendMessage() {
|
|||||||
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive));
|
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Steering (Einwurf in die laufende Chain) ──
|
||||||
|
async function sendSteer(text) {
|
||||||
|
const input = $('#input');
|
||||||
|
// Eingabe leeren + optimistische Bubble (wird am Stream-Ende durch die DB-Version ersetzt)
|
||||||
|
input.value = '';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
const bubble = addSteerBubble(text, true);
|
||||||
|
scrollDownForce();
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await apiChatSteer(getCurrentSessionId(), text);
|
||||||
|
} catch (e) {
|
||||||
|
if (bubble) bubble.remove();
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
addWarningBubble('Einwurf fehlgeschlagen: ' + (e.message || e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.injected) {
|
||||||
|
// Race: die laufende Antwort ist gerade fertig geworden → Text zurücklegen, normal erneut senden
|
||||||
|
if (bubble) bubble.remove();
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
addWarningBubble('Die laufende Antwort ist bereits beendet – bitte erneut senden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Direct Message (für Inline-Choice-Buttons) ──
|
// ── Direct Message (für Inline-Choice-Buttons) ──
|
||||||
async function sendMessageDirect(text) {
|
async function sendMessageDirect(text) {
|
||||||
if (isStreaming || !text) return;
|
if (isStreaming || !text) return;
|
||||||
|
|||||||
@@ -261,6 +261,20 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'steer': {
|
||||||
|
// Live-Einwurf des Users während die Chain lief (User-Stil + Einwurf-Label)
|
||||||
|
if (msg.role === 'user' && msg.content) {
|
||||||
|
fragment.appendChild(_buildSteerBubble({
|
||||||
|
text: msg.content,
|
||||||
|
timestamp: msg.createdAt,
|
||||||
|
messageId: msg.id,
|
||||||
|
isMarked: msg.isMarked,
|
||||||
|
deletedOn: msg.deletedOn,
|
||||||
|
archived: archived
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'auto_context': {
|
case 'auto_context': {
|
||||||
if (msg.content) {
|
if (msg.content) {
|
||||||
const acDiv = document.createElement('div');
|
const acDiv = document.createElement('div');
|
||||||
|
|||||||
Reference in new issue
Block a user