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
@@ -78,6 +78,9 @@ function apiChoiceRespond(sessionId, choice) {
|
||||
|
||||
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 ──
|
||||
|
||||
function apiGetAgents() { return _get('/api/agents'); }
|
||||
|
||||
@@ -157,6 +157,27 @@ function addUserBubble(text, streamed = false, timestamp = '', messageId = null,
|
||||
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) {
|
||||
const div = document.createElement('div');
|
||||
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);
|
||||
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 'info':
|
||||
addWarningBubble(data.message, true);
|
||||
@@ -278,6 +284,14 @@ async function _runStream(fetchPromise) {
|
||||
_stopClickCount = 0;
|
||||
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 {
|
||||
const resp = await fetchPromise(_abortController.signal);
|
||||
const reader = resp.body.getReader();
|
||||
@@ -331,6 +345,11 @@ async function _runStream(fetchPromise) {
|
||||
stopBtn.title = 'Antwort abbrechen';
|
||||
isStreaming = false;
|
||||
_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)
|
||||
var metaEl = $('#meta');
|
||||
if (metaEl && (metaEl.textContent.includes('Streaming') || metaEl.textContent.includes('...'))) {
|
||||
@@ -350,8 +369,14 @@ async function _runStream(fetchPromise) {
|
||||
// ── Send Message ──
|
||||
|
||||
async function sendMessage() {
|
||||
if (isStreaming) return;
|
||||
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();
|
||||
if (!text && pendingFiles.length === 0 && pendingRefs.length === 0) return;
|
||||
|
||||
@@ -424,6 +449,34 @@ async function sendMessage() {
|
||||
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) ──
|
||||
async function sendMessageDirect(text) {
|
||||
if (isStreaming || !text) return;
|
||||
|
||||
@@ -261,6 +261,20 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
||||
}
|
||||
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': {
|
||||
if (msg.content) {
|
||||
const acDiv = document.createElement('div');
|
||||
|
||||
Reference in new issue
Block a user