Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5243b1abb | |||
| eb5c178139 | |||
| 31b0bfaac1 | |||
| 1d3c45fdda | |||
| 84a59d7b4f |
+44
-2
@@ -1677,8 +1677,14 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
if msg_type == "cancel_request":
|
||||
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
|
||||
await self._cancel_via_diagnostic()
|
||||
hard = bool(payload.get("hard"))
|
||||
if hard:
|
||||
logger.warning("[rvs] NOT-AUS — hard cancel: Diagnostic /api/cancel + Proxy /cancel-all")
|
||||
await self._cancel_via_diagnostic()
|
||||
await self._cancel_proxy_subprocesses()
|
||||
else:
|
||||
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
|
||||
await self._cancel_via_diagnostic()
|
||||
await self._emit_activity("idle", "")
|
||||
return
|
||||
|
||||
@@ -2709,6 +2715,24 @@ class ARIABridge:
|
||||
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||
|
||||
async def _cancel_proxy_subprocesses(self) -> None:
|
||||
"""Not-Aus: ruft den proxy-internen /cancel-all Side-Channel auf
|
||||
(siehe proxy-patches/routes.js). Killt alle aktiven Claude-Code-
|
||||
Subprocesses sofort. Bridge ist auf aria-net, Proxy auch — also
|
||||
per Container-Name + Side-Channel-Port (Default 3457) erreichbar."""
|
||||
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/cancel-all"
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
req = urllib.request.Request(url, method="POST", data=b"")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
|
||||
except Exception as e:
|
||||
return f"error: {e}", ""
|
||||
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.warning("[NOT-AUS] proxy /cancel-all: %s %s", status, body)
|
||||
|
||||
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||
|
||||
@@ -2890,6 +2914,24 @@ class ARIABridge:
|
||||
# selbst wenn derselbe Name zweimal in Folge kommt.
|
||||
asyncio.create_task(self._emit_activity("tool", tool, force=True))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/agent-stream":
|
||||
# Vom Proxy gefeuert: voller Live-Stream der Claude-Code-
|
||||
# Session (assistant_text, tool_use mit Input, tool_result
|
||||
# mit truncated Output, start/end Markers). Wir leiten 1:1
|
||||
# als RVS agent_stream an Diagnostic (ARIA-Live-View) und
|
||||
# App weiter — read-only Mirror der gerade laufenden
|
||||
# ARIA-Aktivitaet.
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "agent_stream",
|
||||
"payload": data,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/flux-generate":
|
||||
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
||||
# Render-Request via RVS an die flux-bridge (Gamebox),
|
||||
|
||||
+143
-90
@@ -395,18 +395,29 @@
|
||||
<div class="card" style="margin-top:12px; padding: 8px 0 0 0;">
|
||||
<div style="padding: 0 12px;">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" id="live-tab-ssh" onclick="switchLiveTab('ssh')">SSH Terminal</button>
|
||||
<button class="tab-btn active" id="live-tab-aria" onclick="switchLiveTab('aria')">ARIA Live</button>
|
||||
<button class="tab-btn" id="live-tab-desktop" onclick="switchLiveTab('desktop')">Desktop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#080810; border:1px solid #1E1E2E; border-radius:0 0 6px 6px; position:relative;">
|
||||
<!-- SSH Terminal -->
|
||||
<div id="live-ssh" style="height:350px; padding:4px;">
|
||||
<div id="live-ssh-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;">
|
||||
<button class="btn" onclick="startLiveSSH()" id="btn-live-ssh" style="padding:4px 12px;font-size:11px;">Verbinden</button>
|
||||
<span id="live-ssh-status" style="font-size:11px;color:#8888AA;">Nicht verbunden</span>
|
||||
<!-- ARIA Live (read-only Mirror der Claude-Code-Session) -->
|
||||
<div id="live-aria" style="height:350px; padding:4px; display:flex; flex-direction:column;">
|
||||
<div id="live-aria-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;flex-shrink:0;">
|
||||
<span id="live-aria-status" style="font-size:11px;color:#8888AA;flex:1;">Idle — warte auf ARIA-Aktivitaet</span>
|
||||
<button class="btn" onclick="clearAriaLive()" style="padding:4px 12px;font-size:11px;" title="Live-Mitschrift leeren">Leeren</button>
|
||||
<label style="font-size:11px;color:#8888AA;display:flex;align-items:center;gap:4px;cursor:pointer;" title="Bei jeder neuen Zeile ans Ende scrollen">
|
||||
<input type="checkbox" id="live-aria-autoscroll" checked style="margin:0;"> Auto-Scroll
|
||||
</label>
|
||||
<button class="btn" onclick="ariaPanicStop()"
|
||||
style="padding:4px 14px;font-size:11px;background:#FF3B30;color:#fff;border-color:#FF3B30;font-weight:bold;"
|
||||
title="NOT-AUS: killt alle aktiven Claude-Code-Subprocesses sofort">
|
||||
⛔ Not-Aus
|
||||
</button>
|
||||
</div>
|
||||
<div id="live-aria-stream"
|
||||
style="flex:1;overflow-y:auto;background:#040408;font-family:'Courier New',monospace;font-size:11px;line-height:1.4;color:#C0C0D0;padding:6px 8px;border-top:1px solid #1E1E2E;">
|
||||
<div style="color:#555570;font-style:italic;">Sobald ARIA denkt oder ein Tool nutzt, taucht es hier in Echtzeit auf.</div>
|
||||
</div>
|
||||
<div id="live-ssh-term" style="height:calc(100% - 32px);"></div>
|
||||
</div>
|
||||
<!-- Desktop Viewer -->
|
||||
<div id="live-desktop" style="height:350px; display:none; position:relative;">
|
||||
@@ -1412,6 +1423,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'agent_stream') {
|
||||
appendAriaStreamEvent(msg.payload || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'voice_preview_audio') {
|
||||
const statusEl = document.getElementById('voice-preview-status');
|
||||
const audio = document.getElementById('voice-preview-audio');
|
||||
@@ -1555,8 +1571,8 @@
|
||||
return;
|
||||
}
|
||||
// core_auth WS-Event entfernt — aria-core ist raus.
|
||||
// Live SSH + Desktop
|
||||
if (msg.type?.startsWith('live_ssh_')) { handleLiveSSH(msg); return; }
|
||||
// SSH-Terminal entfernt — durch ARIA-Live-Mirror ersetzt.
|
||||
// Desktop bleibt.
|
||||
if (msg.type === 'desktop_status') { handleDesktop(msg); return; }
|
||||
|
||||
if (msg.type === 'term_ready') {
|
||||
@@ -2962,96 +2978,133 @@
|
||||
|
||||
// ── ARIA Live-Ansicht (SSH + Desktop) ──────────────────
|
||||
|
||||
let liveSshTerm = null;
|
||||
let liveSshFit = null;
|
||||
|
||||
function switchLiveTab(tab) {
|
||||
document.getElementById('live-ssh').style.display = tab === 'ssh' ? 'block' : 'none';
|
||||
document.getElementById('live-aria').style.display = tab === 'aria' ? 'flex' : 'none';
|
||||
document.getElementById('live-desktop').style.display = tab === 'desktop' ? 'block' : 'none';
|
||||
document.getElementById('live-tab-ssh').className = 'tab-btn' + (tab === 'ssh' ? ' active' : '');
|
||||
document.getElementById('live-tab-aria').className = 'tab-btn' + (tab === 'aria' ? ' active' : '');
|
||||
document.getElementById('live-tab-desktop').className = 'tab-btn' + (tab === 'desktop' ? ' active' : '');
|
||||
if (tab === 'ssh' && liveSshTerm && liveSshFit) {
|
||||
setTimeout(() => liveSshFit.fit(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function startLiveSSH() {
|
||||
const statusEl = document.getElementById('live-ssh-status');
|
||||
const btn = document.getElementById('btn-live-ssh');
|
||||
|
||||
// Wenn schon verbunden, trennen
|
||||
if (liveSshTerm && liveSshTerm._sshConnected) {
|
||||
send({ action: 'live_ssh_close' });
|
||||
statusEl.textContent = 'Getrennt';
|
||||
statusEl.style.color = '#FF6B6B';
|
||||
btn.textContent = 'Verbinden';
|
||||
liveSshTerm._sshConnected = false;
|
||||
return;
|
||||
// ── ARIA Live (read-only Mirror der Claude-Code-Session) ──────
|
||||
//
|
||||
// Empfaengt agent_stream Events vom RVS (Proxy → Bridge → RVS → wir).
|
||||
// Rendert sie als monospace-Liste — Tool-Calls in cyan, Tool-Results
|
||||
// in grau (truncated), ARIA-Text in weiss, Thinking kursiv. Auto-Scroll
|
||||
// bleibt am unteren Rand kleben solange der User nicht hochgescrollt hat.
|
||||
// Not-Aus killt via Bridge → Proxy-Side-Channel alle Subprocesses.
|
||||
function _ariaStreamEl() { return document.getElementById('live-aria-stream'); }
|
||||
function _ariaStatusEl() { return document.getElementById('live-aria-status'); }
|
||||
function _ariaIsAtBottom() {
|
||||
const el = _ariaStreamEl();
|
||||
if (!el) return true;
|
||||
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 24;
|
||||
}
|
||||
function _ariaMaybeScroll() {
|
||||
if (!document.getElementById('live-aria-autoscroll')?.checked) return;
|
||||
const el = _ariaStreamEl();
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
// Truncate UI: groessere Backlogs koennen viele MB werden. Wir halten
|
||||
// max 2000 Zeilen — beim Ueberlauf den oberen Block wegwerfen.
|
||||
const ARIA_MAX_LINES = 2000;
|
||||
function _ariaTrimBacklog() {
|
||||
const el = _ariaStreamEl();
|
||||
if (!el) return;
|
||||
while (el.childElementCount > ARIA_MAX_LINES) {
|
||||
el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
statusEl.textContent = 'Verbinde...';
|
||||
statusEl.style.color = '#FFD60A';
|
||||
|
||||
function initSSHTerm() {
|
||||
const container = document.getElementById('live-ssh-term');
|
||||
if (!liveSshTerm) {
|
||||
liveSshTerm = new Terminal({
|
||||
theme: { background: '#080810', foreground: '#E0E0F0', cursor: '#0096FF' },
|
||||
fontFamily: 'Courier New, monospace',
|
||||
fontSize: 12,
|
||||
cursorBlink: true,
|
||||
});
|
||||
liveSshFit = new FitAddon.FitAddon();
|
||||
liveSshTerm.loadAddon(liveSshFit);
|
||||
liveSshTerm.open(container);
|
||||
liveSshFit.fit();
|
||||
liveSshTerm.onData((data) => {
|
||||
send({ action: 'live_ssh_input', data });
|
||||
});
|
||||
}
|
||||
liveSshTerm.clear();
|
||||
send({ action: 'live_ssh_start' });
|
||||
}
|
||||
|
||||
if (typeof Terminal === 'undefined') {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js';
|
||||
s.onload = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js';
|
||||
s2.onload = () => initSSHTerm();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
function _ariaTimePrefix(ts) {
|
||||
try {
|
||||
const d = ts ? new Date(ts) : new Date();
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
function _ariaEsc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function _ariaPushLine(html, color, opts = {}) {
|
||||
const el = _ariaStreamEl();
|
||||
if (!el) return;
|
||||
const wasAtBottom = _ariaIsAtBottom();
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = `color:${color};${opts.style||''}`;
|
||||
row.innerHTML = html;
|
||||
// Erste statische "Sobald ARIA..."-Zeile beim ersten Event entfernen
|
||||
const placeholder = el.querySelector('div[style*="italic"]');
|
||||
if (placeholder && el.childElementCount === 1) el.removeChild(placeholder);
|
||||
el.appendChild(row);
|
||||
_ariaTrimBacklog();
|
||||
if (wasAtBottom) _ariaMaybeScroll();
|
||||
}
|
||||
function appendAriaStreamEvent(p) {
|
||||
const t = _ariaTimePrefix(p.ts);
|
||||
const kind = p.kind || '';
|
||||
if (kind === 'start') {
|
||||
_ariaPushLine(
|
||||
`<span style="color:#444460;">━━━ ${t} session start (${_ariaEsc(p.model || 'unknown')}) ━━━</span>`,
|
||||
'#444460',
|
||||
);
|
||||
const st = _ariaStatusEl(); if (st) { st.textContent = 'ARIA aktiv...'; st.style.color = '#34C759'; }
|
||||
} else if (kind === 'end') {
|
||||
const reason = p.reason || '?';
|
||||
const codePart = (p.code !== undefined && p.code !== null) ? ` code=${_ariaEsc(p.code)}` : '';
|
||||
const errPart = p.error ? ` err=${_ariaEsc(String(p.error).slice(0,120))}` : '';
|
||||
_ariaPushLine(
|
||||
`<span style="color:#444460;">━━━ ${t} session end (${_ariaEsc(reason)}${codePart}${errPart}) ━━━</span>`,
|
||||
'#444460',
|
||||
);
|
||||
const st = _ariaStatusEl(); if (st) { st.textContent = 'Idle'; st.style.color = '#8888AA'; }
|
||||
} else if (kind === 'text') {
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> ${_ariaEsc(p.text || '')}`,
|
||||
'#D0D0E0',
|
||||
{ style: 'white-space:pre-wrap;word-break:break-word;' },
|
||||
);
|
||||
} else if (kind === 'thinking') {
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> <span style="font-style:italic;color:#888866;">💭 ${_ariaEsc(p.text || '')}</span>`,
|
||||
'#888866',
|
||||
{ style: 'white-space:pre-wrap;word-break:break-word;' },
|
||||
);
|
||||
} else if (kind === 'tool_use') {
|
||||
const name = _ariaEsc(p.name || '?');
|
||||
const inp = _ariaEsc(p.input || '');
|
||||
const tail = p.inputTruncatedBytes ? `<span style="color:#777799;"> ...(+${p.inputTruncatedBytes} bytes)</span>` : '';
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> <span style="color:#0096FF;">▶ ${name}</span> <span style="color:#8888AA;">${inp}${tail}</span>`,
|
||||
'#C0C0D0',
|
||||
{ style: 'white-space:pre-wrap;word-break:break-word;' },
|
||||
);
|
||||
} else if (kind === 'tool_result') {
|
||||
const isError = p.isError === true;
|
||||
const head = isError ? '<span style="color:#FF6B6B;">✗ result (ERROR)</span>' : '<span style="color:#34C759;">✓ result</span>';
|
||||
const tail = p.truncatedBytes ? `<span style="color:#777799;"> ...(+${p.truncatedBytes} bytes)</span>` : '';
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> ${head}<br><span style="color:#9090A0;white-space:pre-wrap;display:block;padding-left:14px;border-left:2px solid #2A2A3E;">${_ariaEsc(p.content || '')}${tail}</span>`,
|
||||
'#9090A0',
|
||||
);
|
||||
} else {
|
||||
initSSHTerm();
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> <span style="color:#AAAACC;">${_ariaEsc(kind)}: ${_ariaEsc(JSON.stringify(p))}</span>`,
|
||||
'#AAAACC',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLiveSSH(msg) {
|
||||
const statusEl = document.getElementById('live-ssh-status');
|
||||
const btn = document.getElementById('btn-live-ssh');
|
||||
if (msg.type === 'live_ssh_data' && liveSshTerm) {
|
||||
const raw = atob(msg.data);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
liveSshTerm.write(bytes);
|
||||
} else if (msg.type === 'live_ssh_connected') {
|
||||
statusEl.textContent = 'Verbunden mit aria-wohnung';
|
||||
statusEl.style.color = '#34C759';
|
||||
btn.textContent = 'Trennen';
|
||||
if (liveSshTerm) liveSshTerm._sshConnected = true;
|
||||
} else if (msg.type === 'live_ssh_error') {
|
||||
statusEl.textContent = msg.error || 'Fehler';
|
||||
statusEl.style.color = '#FF6B6B';
|
||||
btn.textContent = 'Verbinden';
|
||||
if (liveSshTerm) liveSshTerm._sshConnected = false;
|
||||
} else if (msg.type === 'live_ssh_closed') {
|
||||
statusEl.textContent = 'Getrennt';
|
||||
statusEl.style.color = '#8888AA';
|
||||
btn.textContent = 'Verbinden';
|
||||
if (liveSshTerm) liveSshTerm._sshConnected = false;
|
||||
}
|
||||
function clearAriaLive() {
|
||||
const el = _ariaStreamEl();
|
||||
if (el) el.innerHTML = '<div style="color:#555570;font-style:italic;">Geleert.</div>';
|
||||
}
|
||||
function ariaPanicStop() {
|
||||
if (!confirm('Wirklich NOT-AUS? Alle aktiven Claude-Subprocesses werden sofort gekillt.')) return;
|
||||
send({ action: 'aria_panic_stop' });
|
||||
_ariaPushLine(
|
||||
`<span style="color:#FF3B30;font-weight:bold;">━━━ ${_ariaTimePrefix()} ⛔ NOT-AUS ausgeloest ━━━</span>`,
|
||||
'#FF3B30',
|
||||
);
|
||||
}
|
||||
|
||||
function checkDesktop() {
|
||||
|
||||
@@ -633,6 +633,11 @@ function connectRVS(forcePlain) {
|
||||
tool: msg.payload?.tool || msg.tool || "",
|
||||
});
|
||||
}
|
||||
} else if (msg.type === "agent_stream") {
|
||||
// Voller Live-Stream der Claude-Code-Session (assistant_text +
|
||||
// tool_use mit Input + tool_result mit truncated Output). Geht
|
||||
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
|
||||
broadcast({ type: "agent_stream", payload: msg.payload });
|
||||
} else if (msg.type === "memory_saved") {
|
||||
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
||||
const m = msg.payload || {};
|
||||
@@ -1887,6 +1892,18 @@ wss.on("connection", (ws) => {
|
||||
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||
} else if (msg.action === "aria_panic_stop") {
|
||||
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
||||
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
||||
log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
// RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
|
||||
// den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
|
||||
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
|
||||
} else if (msg.action === "voice_upload") {
|
||||
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
|
||||
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ services:
|
||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||
|
||||
+10
-3
@@ -10,9 +10,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch CUDA-Wheels zuerst, damit diffusers nicht CPU-Torch zieht.
|
||||
# Versionsmatrix wie bei f5tts gehalten (cu121, Torch 2.3.1) — gleicher
|
||||
# Treiber-Footprint, gleicher HF-Cache-Pfad.
|
||||
RUN pip3 install --no-cache-dir torch==2.3.1 \
|
||||
# Torch 2.5+ ist Pflicht: aktuelle transformers (4.50+, von diffusers
|
||||
# transitiv reingezogen) registriert in integrations/moe.py einen
|
||||
# custom_op mit String-Forward-References (`input: 'torch.Tensor'`).
|
||||
# Erst torch 2.5's infer_schema kann die aufloesen — 2.4.1 crasht mit
|
||||
# "Parameter input has unsupported type torch.Tensor" beim Import von
|
||||
# diffusers.pipelines.flux.pipeline_flux.
|
||||
# torchvision wird von den CLIP-/Siglip-ImageProcessors verlangt.
|
||||
# cu121 bleibt — passt zum CUDA 12.2 Base-Image.
|
||||
RUN pip3 install --no-cache-dir \
|
||||
torch==2.5.1 torchvision==0.20.1 \
|
||||
--index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
+217
-16
@@ -7,6 +7,10 @@
|
||||
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
|
||||
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
|
||||
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
|
||||
* - Voller Live-Stream (assistant_text, tool_use mit input, tool_result)
|
||||
* geht an ARIA_STREAM_HOOK_URL → Bridge → RVS `agent_stream` → Diagnostic
|
||||
* "ARIA Live"-View (TeamViewer-mäßiger Mirror der Claude-Code-Session).
|
||||
* - Subprocess-Tracking + POST /v1/cancel-all fuer Not-Aus (Hard-Kill).
|
||||
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
|
||||
* der Brain-Call NICHT ab.
|
||||
*
|
||||
@@ -21,42 +25,180 @@ import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js
|
||||
|
||||
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|
||||
|| "http://aria-bridge:8090/internal/agent-activity";
|
||||
const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
|
||||
|| "http://aria-bridge:8090/internal/agent-stream";
|
||||
|
||||
// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
|
||||
// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
|
||||
// "...(N bytes truncated)" Hinweis. Vollstaendiger Output bleibt im Brain
|
||||
// und wird normal verarbeitet, das hier ist NUR fuer den Live-Mirror.
|
||||
const TOOL_RESULT_MAX_CHARS = 4096;
|
||||
const TOOL_INPUT_MAX_CHARS = 2048;
|
||||
|
||||
// Idle-Timeout: Subprocess wird gekillt wenn ueber IDLE_TIMEOUT_MS keine
|
||||
// Aktivitaet (message/content_delta) ankommt. Loest das alte Hard-Timeout-
|
||||
// Problem fuer lange Agent-Sessions (Pentests etc.) — ARIA darf ewig
|
||||
// arbeiten solange sie regelmaessig was emittiert, aber wenn der Subprocess
|
||||
// hartnaeckig haengt, schlaegt der Watchdog trotzdem zu.
|
||||
// Default 20min Idle. Override via env ARIA_IDLE_TIMEOUT_MS.
|
||||
// 0 = deaktiviert (nicht empfohlen).
|
||||
const IDLE_TIMEOUT_MS = parseInt(process.env.ARIA_IDLE_TIMEOUT_MS || "1200000", 10);
|
||||
|
||||
/**
|
||||
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits,
|
||||
* keine Fehler nach oben. Logged Fehler still.
|
||||
* Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
|
||||
* nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
|
||||
*/
|
||||
function _emitToolEvent(toolName) {
|
||||
if (!toolName) return;
|
||||
function _postJson(url, body) {
|
||||
try {
|
||||
const u = new URL(TOOL_HOOK_URL);
|
||||
const body = JSON.stringify({ tool: String(toolName) });
|
||||
const u = new URL(url);
|
||||
const data = JSON.stringify(body);
|
||||
const req = http.request({
|
||||
method: "POST",
|
||||
hostname: u.hostname,
|
||||
port: u.port || 80,
|
||||
path: u.pathname,
|
||||
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
||||
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
|
||||
timeout: 2000,
|
||||
}, (res) => { res.resume(); });
|
||||
req.on("error", () => {});
|
||||
req.on("timeout", () => req.destroy());
|
||||
req.write(body);
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (_) { /* niemals weiterwerfen */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
|
||||
* kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live.
|
||||
* Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
|
||||
*/
|
||||
function _attachToolHook(subprocess) {
|
||||
function _emitToolEvent(toolName) {
|
||||
if (!toolName) return;
|
||||
_postJson(TOOL_HOOK_URL, { tool: String(toolName) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pusht ein Stream-Event an die Bridge (neuer "ARIA Live"-Pfad).
|
||||
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
|
||||
*/
|
||||
function _emitStreamEvent(requestId, kind, fields) {
|
||||
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
|
||||
}
|
||||
|
||||
function _truncate(str, max) {
|
||||
if (typeof str !== "string") str = String(str ?? "");
|
||||
if (str.length <= max) return { text: str, truncatedBytes: 0 };
|
||||
return { text: str.slice(0, max), truncatedBytes: str.length - max };
|
||||
}
|
||||
|
||||
// ── Subprocess-Tracking fuer Not-Aus ──────────────────────────
|
||||
// requestId → ClaudeSubprocess. Eintraege werden beim close/result-Event
|
||||
// wieder entfernt. /v1/cancel-all iteriert und ruft .kill() auf jeden.
|
||||
const _activeSubprocesses = new Map();
|
||||
function _trackSubprocess(requestId, subprocess) {
|
||||
_activeSubprocesses.set(requestId, subprocess);
|
||||
const cleanup = () => _activeSubprocesses.delete(requestId);
|
||||
subprocess.on("close", cleanup);
|
||||
subprocess.on("error", cleanup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle-Watchdog: killt den Subprocess wenn ueber IDLE_TIMEOUT_MS hinweg
|
||||
* keine message/content_delta Events ankommen. Wird beim Start gesetzt,
|
||||
* bei jedem Event reset, bei close/error/result gestoppt.
|
||||
*
|
||||
* Stream-Event 'end' wird durch den normalen close-Listener im Handler
|
||||
* gefeuert — wir muessen hier nichts extra emittieren.
|
||||
*/
|
||||
function _attachIdleWatchdog(subprocess, requestId) {
|
||||
if (!IDLE_TIMEOUT_MS || IDLE_TIMEOUT_MS <= 0) return; // disabled
|
||||
let timer = null;
|
||||
let killed = false;
|
||||
|
||||
function _kill() {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
const mins = Math.round(IDLE_TIMEOUT_MS / 60000);
|
||||
console.warn(`[aria-idle] killing subprocess ${requestId} after ${mins}min idle`);
|
||||
try { subprocess.kill(); } catch (_) {}
|
||||
_emitStreamEvent(requestId, "end", { reason: "idle_timeout", idleMs: IDLE_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
function _reset() {
|
||||
if (killed) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(_kill, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function _stop() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
}
|
||||
|
||||
// Initial-Timer setzen
|
||||
_reset();
|
||||
|
||||
// Jedes Event vom Subprozess zaehlt als Lebenszeichen
|
||||
subprocess.on("message", _reset);
|
||||
subprocess.on("content_delta", _reset);
|
||||
// Result/close/error → endgueltig stop
|
||||
subprocess.on("result", _stop);
|
||||
subprocess.on("close", _stop);
|
||||
subprocess.on("error", _stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hookt assistant + user Events und pusht beides an Bridge:
|
||||
* - Alt-API: nur Tool-Namen an /internal/agent-activity (Gedanken-Stream)
|
||||
* - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
|
||||
*/
|
||||
function _attachToolHook(subprocess, requestId) {
|
||||
subprocess.on("assistant", (message) => {
|
||||
try {
|
||||
const blocks = message?.message?.content || [];
|
||||
for (const b of blocks) {
|
||||
if (b && b.type === "tool_use" && b.name) {
|
||||
_emitToolEvent(b.name);
|
||||
if (!b) continue;
|
||||
if (b.type === "tool_use") {
|
||||
if (b.name) _emitToolEvent(b.name);
|
||||
const inputStr = b.input ? JSON.stringify(b.input) : "";
|
||||
const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
|
||||
_emitStreamEvent(requestId, "tool_use", {
|
||||
id: b.id || null,
|
||||
name: b.name || "",
|
||||
input: inp.text,
|
||||
inputTruncatedBytes: inp.truncatedBytes,
|
||||
});
|
||||
} else if (b.type === "text" && b.text) {
|
||||
_emitStreamEvent(requestId, "text", { text: b.text });
|
||||
} else if (b.type === "thinking" && b.thinking) {
|
||||
// Wenn das Modell Extended Thinking emittiert — selten in
|
||||
// Claude Code CLI, aber moeglich. Markieren wir extra.
|
||||
_emitStreamEvent(requestId, "thinking", { text: b.thinking });
|
||||
}
|
||||
}
|
||||
} catch (_) { /* fail-open */ }
|
||||
});
|
||||
// tool_result Blocks kommen in user-Messages — die werden vom
|
||||
// subprocess-Manager NICHT als 'user'-Event emittiert (gibt's nicht),
|
||||
// sondern nur ueber das generische 'message'-Event mit type:'user'.
|
||||
// 'message' feuert auch fuer assistant/result — wir filtern auf user
|
||||
// damit wir nicht doppelt rendern (assistant geht ueber den eigenen
|
||||
// assistant-Handler oben).
|
||||
subprocess.on("message", (message) => {
|
||||
try {
|
||||
if (message?.type !== "user") return;
|
||||
const blocks = message?.message?.content || [];
|
||||
for (const b of blocks) {
|
||||
if (b && b.type === "tool_result") {
|
||||
let content = "";
|
||||
if (typeof b.content === "string") content = b.content;
|
||||
else if (Array.isArray(b.content)) {
|
||||
content = b.content.map(c => (c && c.type === "text" && c.text) ? c.text : "").join("");
|
||||
}
|
||||
const out = _truncate(content, TOOL_RESULT_MAX_CHARS);
|
||||
_emitStreamEvent(requestId, "tool_result", {
|
||||
id: b.tool_use_id || null,
|
||||
content: out.text,
|
||||
truncatedBytes: out.truncatedBytes,
|
||||
isError: b.is_error === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) { /* fail-open */ }
|
||||
@@ -86,9 +228,17 @@ export async function handleChatCompletions(req, res) {
|
||||
// Convert to CLI input format
|
||||
const cliInput = openaiToCli(body);
|
||||
const subprocess = new ClaudeSubprocess();
|
||||
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
|
||||
// Greift fuer beide Branches (stream + non-stream).
|
||||
_attachToolHook(subprocess);
|
||||
// ARIA-Patch: Tool-Use-Events + voller Live-Stream an die Bridge.
|
||||
// Plus: Subprocess fuer Not-Aus tracken (Hard-Kill via /v1/cancel-all).
|
||||
// Plus: Idle-Watchdog — Subprocess darf ewig laufen solange Events
|
||||
// kommen, wird aber gekillt nach IDLE_TIMEOUT_MS Inaktivitaet.
|
||||
_attachToolHook(subprocess, requestId);
|
||||
_trackSubprocess(requestId, subprocess);
|
||||
_attachIdleWatchdog(subprocess, requestId);
|
||||
_emitStreamEvent(requestId, "start", { model: body.model || null });
|
||||
subprocess.on("result", () => _emitStreamEvent(requestId, "end", { reason: "result" }));
|
||||
subprocess.on("close", (code) => _emitStreamEvent(requestId, "end", { reason: "close", code }));
|
||||
subprocess.on("error", (err) => _emitStreamEvent(requestId, "end", { reason: "error", error: String(err?.message || err) }));
|
||||
if (stream) {
|
||||
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
|
||||
}
|
||||
@@ -306,4 +456,55 @@ export function handleHealth(_req, res) {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Not-Aus Side-Channel ───────────────────────────────────
|
||||
//
|
||||
// claude-max-api-proxy steuert seine eigene Route-Registrierung — wir
|
||||
// koennen da nicht reinpatchen ohne sed-Operationen am npm-Paket. Saubrer:
|
||||
// ein dedizierter kleiner HTTP-Listener nur fuer den Not-Aus, auf einem
|
||||
// internen Port im aria-net. Bridge ruft den, killt alle aktiven Claude-
|
||||
// Subprocesses. App + Diagnostic sehen den Stream sofort enden.
|
||||
const INTERNAL_PORT = parseInt(process.env.ARIA_PROXY_INTERNAL_PORT || "3457", 10);
|
||||
const INTERNAL_HOST = "0.0.0.0"; // im aria-net erreichbar, nicht nach extern exposed
|
||||
|
||||
function _cancelAll() {
|
||||
const ids = Array.from(_activeSubprocesses.keys());
|
||||
let killed = 0;
|
||||
for (const [id, subp] of _activeSubprocesses) {
|
||||
try {
|
||||
subp.kill();
|
||||
killed++;
|
||||
} catch (e) {
|
||||
console.error("[aria-not-aus] kill failed for", id, e?.message);
|
||||
}
|
||||
}
|
||||
_activeSubprocesses.clear();
|
||||
return { killed, requestIds: ids };
|
||||
}
|
||||
|
||||
try {
|
||||
const internalServer = http.createServer((req, res) => {
|
||||
if (req.method === "POST" && req.url === "/cancel-all") {
|
||||
const result = _cancelAll();
|
||||
console.warn("[aria-not-aus] /cancel-all — killed", result.killed, "subprocess(es)");
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, ...result }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
internalServer.on("error", (err) => {
|
||||
console.error("[aria-not-aus] internal listener error:", err.message);
|
||||
});
|
||||
internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => {
|
||||
console.log("[aria-not-aus] internal listener on", INTERNAL_HOST + ":" + INTERNAL_PORT);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[aria-not-aus] startup failed:", e?.message);
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
@@ -40,6 +40,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"service_status",
|
||||
"config_request",
|
||||
"flux_request", "flux_response",
|
||||
"agent_stream",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
|
||||
Reference in New Issue
Block a user