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:
MelekiandClaude Opus 4.8 committed 2026-06-02 06:49:58 +02:00
1 parent a58f279eaa
commit f2fd493e5f
12 files changed
+555 -132

No files matched your search

+10 -1
View File
@@ -294,7 +294,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
{
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>
{
@@ -362,6 +362,15 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
if (content.Count > 0)
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)
{
// Thinking-Inhalt für OpenAI-kompatible Provider mitführen.