Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091a1b7755 | ||
|
|
e2b1eced3c | ||
|
|
fe804fa40e | ||
|
|
18eb94e942 | ||
|
|
74c7ea0a2d | ||
|
|
648e3b04fd | ||
|
|
2ad8c2f245 | ||
|
|
85d190e98c | ||
|
|
70705269fd | ||
|
|
2aef0347ae | ||
|
|
a49c022308 | ||
|
|
2d3ba024a4 | ||
|
|
1c7157327c | ||
|
|
6c27097c96 | ||
|
|
a91325a04f | ||
|
|
ef826d1ed1 | ||
|
|
933836f0a6 | ||
|
|
e04d8f360b | ||
|
|
4685632294 | ||
|
|
769025c41b | ||
|
|
2005e9b85e | ||
|
|
25abd220ad |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20204
|
versionCode 20300
|
||||||
versionName "0.2.2.4"
|
versionName "0.2.3.0"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.2.4",
|
"version": "0.2.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -2371,6 +2371,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
base64,
|
base64,
|
||||||
projectId: activePid,
|
projectId: activePid,
|
||||||
|
// Korrelation: dieselbe clientMsgId wie der Text, damit die Bridge
|
||||||
|
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
|
||||||
|
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
|
||||||
|
...(cmid && { clientMsgId: cmid }),
|
||||||
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
||||||
...(location && { location }),
|
...(location && { location }),
|
||||||
});
|
});
|
||||||
@@ -2440,6 +2444,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]);
|
}, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]);
|
||||||
|
|
||||||
|
// Zwischenruf: waehrend ARIA arbeitet eine Korrektur MITTEN in den laufenden
|
||||||
|
// Turn schieben — NICHT in die Queue, KEIN Abbruch. Geht als 'interject' ueber
|
||||||
|
// RVS an die Bridge → Proxy → laufender Subprozess (greift es an der naechsten
|
||||||
|
// Tool-Grenze auf). Sichtbar nur, wenn der aktive Kontext gerade arbeitet.
|
||||||
|
const sendInterject = useCallback(() => {
|
||||||
|
const text = inputText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const activePid = focusedProjectIdRef.current;
|
||||||
|
rvs.send('interject' as any, { projectId: activePid, text });
|
||||||
|
// Lokale Bubble zur Rueckmeldung (laeuft NICHT durch Send/Queue).
|
||||||
|
setMessages(prev => capMessages([...prev, {
|
||||||
|
id: nextId(), sender: 'user', text: `📣 Zwischenruf: ${text}`,
|
||||||
|
timestamp: Date.now(), projectId: activePid,
|
||||||
|
}]));
|
||||||
|
projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
|
||||||
|
setInputText('');
|
||||||
|
}, [inputText]);
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
|
|
||||||
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
||||||
@@ -3214,9 +3236,19 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
{/* Senden oder Sprache */}
|
{/* Senden oder Sprache */}
|
||||||
{inputText.trim() || pendingAttachments.length > 0 ? (
|
{inputText.trim() || pendingAttachments.length > 0 ? (
|
||||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
<>
|
||||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
{/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und
|
||||||
</TouchableOpacity>
|
Text da ist. Schiebt die Korrektur in den laufenden Turn statt
|
||||||
|
sie anzustellen. */}
|
||||||
|
{inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||||
|
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
|
||||||
|
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||||
|
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<VoiceButton
|
<VoiceButton
|
||||||
@@ -3734,6 +3766,18 @@ const styles = StyleSheet.create({
|
|||||||
sendIcon: {
|
sendIcon: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
},
|
},
|
||||||
|
interjectButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#FF9500', // orange — Zwischenruf, klar vom blauen Senden getrennt
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
interjectIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
wakeWordBtn: {
|
wakeWordBtn: {
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Platform, DeviceEventEmitter } from 'react-native';
|
import { Platform, DeviceEventEmitter, AppState } from 'react-native';
|
||||||
import rvs from './rvs';
|
import rvs from './rvs';
|
||||||
|
|
||||||
// Lokales Event damit die SettingsScreen Live Logs / Events Tabs
|
// Lokales Event damit die SettingsScreen Live Logs / Events Tabs
|
||||||
@@ -38,6 +38,23 @@ const noop = () => {};
|
|||||||
let _verbose = true;
|
let _verbose = true;
|
||||||
let _debugLogsToBridge = false;
|
let _debugLogsToBridge = false;
|
||||||
|
|
||||||
|
// ─── Crash-Kontext ohne adb ─────────────────────────────────────────
|
||||||
|
// Ein RUN_MARKER bleibt gesetzt, solange die App AKTIV laeuft; bei sauberem
|
||||||
|
// Wechsel in den Hintergrund wird er geloescht. Ist er beim naechsten Start
|
||||||
|
// noch da, ist der vorige Lauf unsauber gestorben (nativer Crash/OOM — der
|
||||||
|
// schreibt KEINEN JS-Fehler, taucht also sonst nirgends auf). Wir melden das
|
||||||
|
// dann via RVS mit dem letzten Breadcrumb (was die App zuletzt tat).
|
||||||
|
const RUN_MARKER_KEY = 'aria_run_marker';
|
||||||
|
const BREADCRUMB_KEY = 'aria_last_breadcrumb';
|
||||||
|
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
||||||
|
let _breadcrumbDirty = false;
|
||||||
|
|
||||||
|
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
|
||||||
|
export function noteBreadcrumb(scope: string, message: string): void {
|
||||||
|
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
||||||
|
_breadcrumbDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
function applyState(): void {
|
function applyState(): void {
|
||||||
console.log = _verbose ? originalLog : noop;
|
console.log = _verbose ? originalLog : noop;
|
||||||
}
|
}
|
||||||
@@ -53,6 +70,45 @@ export async function initLogger(): Promise<void> {
|
|||||||
_debugLogsToBridge = d === 'true'; // default: false
|
_debugLogsToBridge = d === 'true'; // default: false
|
||||||
} catch {}
|
} catch {}
|
||||||
applyState();
|
applyState();
|
||||||
|
await _initCrashDetection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native-Crash-Erkennung (ohne adb) — siehe RUN_MARKER-Kommentar oben.
|
||||||
|
async function _initCrashDetection(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const marker = await AsyncStorage.getItem(RUN_MARKER_KEY);
|
||||||
|
if (marker) {
|
||||||
|
let bc: any = {};
|
||||||
|
try { bc = JSON.parse((await AsyncStorage.getItem(BREADCRUMB_KEY)) || '{}'); } catch {}
|
||||||
|
const gap = bc && bc.ts ? Math.round((Date.now() - bc.ts) / 1000) : -1;
|
||||||
|
// Verzoegert melden — RVS ist beim Boot oft noch nicht verbunden.
|
||||||
|
setTimeout(() => {
|
||||||
|
reportAppError({
|
||||||
|
scope: 'app.crash-detected',
|
||||||
|
level: 'warn',
|
||||||
|
message: `Voriger Lauf ohne sauberes Shutdown beendet (nativer Crash/OOM?). `
|
||||||
|
+ `Letzte Aktivitaet: [${(bc && bc.scope) || '?'}] ${(bc && bc.message) || '?'}`
|
||||||
|
+ (gap >= 0 ? ` (vor ~${gap}s)` : ''),
|
||||||
|
});
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
await AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now()));
|
||||||
|
} catch {}
|
||||||
|
// Breadcrumb throttled persistieren (alle 5s, nur wenn geaendert).
|
||||||
|
setInterval(() => {
|
||||||
|
if (_breadcrumbDirty) {
|
||||||
|
_breadcrumbDirty = false;
|
||||||
|
AsyncStorage.setItem(BREADCRUMB_KEY, JSON.stringify(_breadcrumb)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
// Sauberer Hintergrund-Wechsel → Marker weg (kein Crash). Rueckkehr → wieder
|
||||||
|
// scharf. So melden nur echte Aktiv-Crashes, kein normales Backgrounden.
|
||||||
|
try {
|
||||||
|
AppState.addEventListener('change', (s) => {
|
||||||
|
if (s === 'background') AsyncStorage.removeItem(RUN_MARKER_KEY).catch(() => {});
|
||||||
|
else if (s === 'active') AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now())).catch(() => {});
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVerboseLogging(): boolean {
|
export function isVerboseLogging(): boolean {
|
||||||
@@ -94,6 +150,7 @@ let _reportingInstalled = false;
|
|||||||
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
||||||
export function reportAppError(ev: AppErrorEvent): void {
|
export function reportAppError(ev: AppErrorEvent): void {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
noteBreadcrumb(ev.scope, ev.message);
|
||||||
try {
|
try {
|
||||||
rvs.send('app_log' as any, {
|
rvs.send('app_log' as any, {
|
||||||
ts,
|
ts,
|
||||||
@@ -128,6 +185,9 @@ export function reportAppError(ev: AppErrorEvent): void {
|
|||||||
* Default aus damit Mama-Modus keine Disk-Schreiblast hat. Error-Reports
|
* Default aus damit Mama-Modus keine Disk-Schreiblast hat. Error-Reports
|
||||||
* (reportAppError) gehen weiterhin IMMER durch. */
|
* (reportAppError) gehen weiterhin IMMER durch. */
|
||||||
export function reportAppDebug(scope: string, message: string): void {
|
export function reportAppDebug(scope: string, message: string): void {
|
||||||
|
// Breadcrumb IMMER aktualisieren (auch wenn Debug-Logs-an-Bridge aus ist) —
|
||||||
|
// fuer den Crash-Kontext beim naechsten Boot.
|
||||||
|
noteBreadcrumb(scope, message);
|
||||||
if (!_debugLogsToBridge) return;
|
if (!_debugLogsToBridge) return;
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const trimmed = String(message).slice(0, 2000);
|
const trimmed = String(message).slice(0, 2000);
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
||||||
*
|
*
|
||||||
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
||||||
* Desktop gruen, wenn eine VM verbunden ist.
|
* Desktop gruen NUR, wenn im aktiven Projekt eine VM laeuft.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
||||||
import codeFile from '../services/codeFile';
|
import codeFile from '../services/codeFile';
|
||||||
import desktop from '../services/desktop';
|
import brainApi from '../services/brainApi';
|
||||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||||
import ChatScreen from '../screens/ChatScreen';
|
import ChatScreen from '../screens/ChatScreen';
|
||||||
import { TileId } from './layout';
|
import { TileId } from './layout';
|
||||||
@@ -29,7 +29,6 @@ const WorkspaceScreen: React.FC = () => {
|
|||||||
useEffect(() => projectFocus.subscribe(setFocus), []);
|
useEffect(() => projectFocus.subscribe(setFocus), []);
|
||||||
|
|
||||||
const pid = focus.focusedProjectId;
|
const pid = focus.focusedProjectId;
|
||||||
const kind = projectFocus.getProjectKind(pid);
|
|
||||||
|
|
||||||
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,13 +38,21 @@ const WorkspaceScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [pid]);
|
}, [pid]);
|
||||||
|
|
||||||
// Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts.
|
// Desktop-Signal: gruener Punkt NUR, wenn im AKTIVEN Projekt wirklich eine VM
|
||||||
|
// laeuft (nicht generell irgendwo). Quelle ist die projektbezogene VM-Liste;
|
||||||
|
// leichtes Nachfassen, damit Start/Stop sich zeitnah zeigt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasDesktop(desktop.getStatus().available);
|
if (!pid) { setHasDesktop(false); return; }
|
||||||
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
|
let alive = true;
|
||||||
if (kind === 'code') desktop.requestCheck(pid);
|
const check = () => {
|
||||||
return unsub;
|
brainApi.listProjectVms(pid)
|
||||||
}, [pid, kind]);
|
.then(r => { if (alive) setHasDesktop((r.vms || []).some(v => v.running)); })
|
||||||
|
.catch(() => { if (alive) setHasDesktop(false); });
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
const t = setInterval(check, 6000);
|
||||||
|
return () => { alive = false; clearInterval(t); };
|
||||||
|
}, [pid]);
|
||||||
|
|
||||||
const badges = useMemo(() => ({
|
const badges = useMemo(() => ({
|
||||||
editor: hasCode ? '#0096FF' : undefined,
|
editor: hasCode ? '#0096FF' : undefined,
|
||||||
|
|||||||
@@ -77,14 +77,13 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
|
|
||||||
var msg=document.getElementById('msg');
|
var msg=document.getElementById('msg');
|
||||||
|
|
||||||
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
|
// Tastatur laeuft NICHT mehr ueber ein verstecktes WebView-Feld (Android
|
||||||
var kbd=document.createElement('input');
|
// oeffnet die Software-Tastatur dafuer unzuverlaessig). Stattdessen haelt die
|
||||||
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
// App ein echtes RN-<TextInput> und ruft window.ariaVncKey.* per
|
||||||
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
// injectJavaScript auf → wird unten (nach RFB-Init) definiert.
|
||||||
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
|
// cp<0x100 → Keysym == Codepoint (Latin-1)
|
||||||
document.body.appendChild(kbd);
|
// sonst → X11-Unicode-Keysym 0x01000000+cp
|
||||||
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
|
function cpToKeysym(cp){ return cp < 0x100 ? cp : 0x01000000 + cp; }
|
||||||
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
|
|
||||||
|
|
||||||
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
||||||
var RFB = mod.default;
|
var RFB = mod.default;
|
||||||
@@ -99,19 +98,29 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
});
|
});
|
||||||
window.__rfb = rfb;
|
window.__rfb = rfb;
|
||||||
|
|
||||||
// Tasten aus dem versteckten Feld an die VM schicken.
|
// Down+Up einer Taste an die VM schicken.
|
||||||
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
||||||
kbd.addEventListener('keydown', function(e){
|
|
||||||
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
// Empfaenger-API: die App (RN-<TextInput> + Sondertasten-Leiste) ruft das
|
||||||
});
|
// per injectJavaScript.
|
||||||
kbd.addEventListener('input', function(){
|
// char(cp) druckbares Zeichen (Codepoint)
|
||||||
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
|
// keysym(ks) Sondertaste als fertiges X11-Keysym (Enter/Esc/F1/…)
|
||||||
});
|
// combo(mods,ks) Modifier(-Keysyms) halten → Taste → wieder loslassen
|
||||||
|
// (Strg+C, Strg+Alt+Entf, …). mods = Array von Keysyms.
|
||||||
|
window.ariaVncKey = {
|
||||||
|
char: function(cp){ tap(cpToKeysym(cp)); },
|
||||||
|
keysym: function(ks){ tap(ks); },
|
||||||
|
combo: function(mods, ks){
|
||||||
|
try{
|
||||||
|
for(var i=0;i<mods.length;i++) rfb.sendKey(mods[i], null, true);
|
||||||
|
rfb.sendKey(ks, null, true); rfb.sendKey(ks, null, false);
|
||||||
|
for(var j=mods.length-1;j>=0;j--) rfb.sendKey(mods[j], null, false);
|
||||||
|
}catch(_){}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Steuerungs-API fuer die App (per injectJavaScript).
|
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||||
window.ariaVncCtl = {
|
window.ariaVncCtl = {
|
||||||
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
|
|
||||||
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
|
|
||||||
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
||||||
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
|
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
|
||||||
setLoading(true); setErr('');
|
setLoading(true); setErr('');
|
||||||
brainApi.listProjectVms(projectId)
|
brainApi.listProjectVms(projectId)
|
||||||
.then(r => setVms(r.vms || []))
|
.then(r => setVms(r.vms || []))
|
||||||
@@ -71,21 +72,6 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbunden → noVNC-Ansicht der VM + Zurück-Leiste.
|
|
||||||
if (connected) {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.bar}>
|
|
||||||
<TouchableOpacity onPress={() => setConnected(null)} style={styles.barBtn}>
|
|
||||||
<Text style={styles.barBtnText}>‹ VMs</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.barTitle} numberOfLines={1}>{connected.name} · :{connected.vnc_display}</Text>
|
|
||||||
</View>
|
|
||||||
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.bar}>
|
<View style={styles.bar}>
|
||||||
@@ -97,6 +83,8 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||||
) : err ? (
|
) : err ? (
|
||||||
<Text style={styles.err}>{err}</Text>
|
<Text style={styles.err}>{err}</Text>
|
||||||
|
) : !projectId ? (
|
||||||
|
<Text style={styles.empty}>Kein aktives Projekt — wechsle in ein Projekt für dessen VMs.</Text>
|
||||||
) : vms.length === 0 ? (
|
) : vms.length === 0 ? (
|
||||||
<Text style={styles.empty}>
|
<Text style={styles.empty}>
|
||||||
Noch keine VM in diesem Projekt.{'\n'}
|
Noch keine VM in diesem Projekt.{'\n'}
|
||||||
@@ -157,6 +145,18 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
|
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
|
||||||
|
{connected && (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
|
||||||
|
<View style={styles.fs}>
|
||||||
|
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
||||||
|
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
|
||||||
|
<Text style={styles.fsBackText}>‹ VMs</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -180,6 +180,9 @@ const styles = StyleSheet.create({
|
|||||||
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
||||||
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
|
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
|
||||||
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
||||||
|
fs: { flex: 1, backgroundColor: '#000000' },
|
||||||
|
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
|
||||||
|
fsBackText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||||
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
||||||
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
|
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
|
||||||
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
|
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
const [previewBusy, setPreviewBusy] = useState('');
|
const [previewBusy, setPreviewBusy] = useState('');
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
if (!projectId) { setFiles([]); setErr(''); setLoading(false); return; }
|
||||||
setLoading(true); setErr('');
|
setLoading(true); setErr('');
|
||||||
brainApi.listProjectFiles(projectId)
|
brainApi.listProjectFiles(projectId)
|
||||||
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
|
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
|
||||||
@@ -88,7 +89,7 @@ const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
) : err ? (
|
) : err ? (
|
||||||
<Text style={styles.err}>{err}</Text>
|
<Text style={styles.err}>{err}</Text>
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<Text style={styles.empty}>Noch keine Dateien in diesem Projekt.</Text>
|
<Text style={styles.empty}>{!projectId ? 'Kein aktives Projekt — wechsle in ein Projekt für dessen Dateien.' : 'Noch keine Dateien in diesem Projekt.'}</Text>
|
||||||
) : (
|
) : (
|
||||||
files.map(f => (
|
files.map(f => (
|
||||||
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
|
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||||
*
|
*
|
||||||
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
||||||
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht
|
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Zwei Bedien-Leisten machen die VM auf
|
||||||
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
|
* dem Handy voll bedienbar:
|
||||||
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
|
* - ctlBar (oben rechts): Fn-Leiste ein/aus, Software-Tastatur, Fit ↔ 1:1.
|
||||||
|
* - keyBar (oben, Fn): echte Steuertasten, die keine Software-Tastatur
|
||||||
|
* liefert — Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/Entf, Enter, F1–F12 und
|
||||||
|
* Sticky-Modifier Strg/Alt/Shift (fuer Strg+C, Strg+Alt+Entf, …).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, View } from 'react-native';
|
||||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||||
import desktop from '../../services/desktop';
|
import desktop from '../../services/desktop';
|
||||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||||
@@ -19,9 +22,30 @@ interface Props {
|
|||||||
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X11-Keysyms fuer Sondertasten, die kein druckbares Zeichen liefern.
|
||||||
|
const KEYSYM = { Backspace: 0xff08, Enter: 0xff0d, Tab: 0xff09 };
|
||||||
|
const MOD = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1 };
|
||||||
|
const cpToKeysym = (cp: number) => (cp < 0x100 ? cp : 0x01000000 + cp);
|
||||||
|
|
||||||
|
// Sondertasten fuer die Fn-Leiste (Label → Keysym).
|
||||||
|
const NAV_KEYS: { label: string; ks: number }[] = [
|
||||||
|
{ label: 'Esc', ks: 0xff1b }, { label: 'Tab', ks: 0xff09 },
|
||||||
|
{ label: '←', ks: 0xff51 }, { label: '↑', ks: 0xff52 }, { label: '↓', ks: 0xff54 }, { label: '→', ks: 0xff53 },
|
||||||
|
{ label: 'Pos1', ks: 0xff50 }, { label: 'Ende', ks: 0xff57 },
|
||||||
|
{ label: 'Bild↑', ks: 0xff55 }, { label: 'Bild↓', ks: 0xff56 },
|
||||||
|
{ label: 'Einfg', ks: 0xff63 }, { label: 'Entf', ks: 0xffff }, { label: '⏎', ks: 0xff0d },
|
||||||
|
];
|
||||||
|
const F_KEYS: { label: string; ks: number }[] = Array.from({ length: 12 }, (_, i) => ({ label: 'F' + (i + 1), ks: 0xffbe + i }));
|
||||||
|
|
||||||
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||||
const webRef = useRef<WebView>(null);
|
const webRef = useRef<WebView>(null);
|
||||||
|
const kbdRef = useRef<TextInput>(null);
|
||||||
|
const bufRef = useRef(''); // Spiegel des TextInput-Textes
|
||||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||||
|
const [kbdOn, setKbdOn] = useState(false);
|
||||||
|
const [keyBar, setKeyBar] = useState(false); // Fn-Leiste sichtbar?
|
||||||
|
const [mods, setMods] = useState({ ctrl: false, alt: false, shift: false });
|
||||||
|
const modRef = useRef({ ctrl: false, alt: false, shift: false }); // Spiegel fuer Closures
|
||||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||||
|
|
||||||
const teardown = useCallback(() => {
|
const teardown = useCallback(() => {
|
||||||
@@ -34,10 +58,87 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
|||||||
return () => teardown();
|
return () => teardown();
|
||||||
}, [focused, teardown]);
|
}, [focused, teardown]);
|
||||||
|
|
||||||
|
// Button-Zustand an die ECHTE Tastatur-Sichtbarkeit koppeln: Androids
|
||||||
|
// Zurueck-Taste blendet die Tastatur aus, ohne den TextInput zu blurren —
|
||||||
|
// ueber keyboardDidHide setzen wir das ⌨-Symbol trotzdem zurueck.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused) return;
|
||||||
|
const show = Keyboard.addListener('keyboardDidShow', () => setKbdOn(true));
|
||||||
|
const hide = Keyboard.addListener('keyboardDidHide', () => setKbdOn(false));
|
||||||
|
return () => { show.remove(); hide.remove(); };
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
const ctl = useCallback((fn: string) => {
|
const ctl = useCallback((fn: string) => {
|
||||||
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const sendKeysym = useCallback((ks: number) => {
|
||||||
|
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`);
|
||||||
|
}, []);
|
||||||
|
const sendCombo = useCallback((modKeysyms: number[], ks: number) => {
|
||||||
|
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.combo(${JSON.stringify(modKeysyms)}, ${ks}); true;`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Aktive Sticky-Modifier als Keysym-Liste; nach dem Anwenden one-shot zuruecksetzen.
|
||||||
|
const activeMods = useCallback(() => {
|
||||||
|
const m = modRef.current; const a: number[] = [];
|
||||||
|
if (m.ctrl) a.push(MOD.ctrl); if (m.alt) a.push(MOD.alt); if (m.shift) a.push(MOD.shift);
|
||||||
|
return a;
|
||||||
|
}, []);
|
||||||
|
const clearMods = useCallback(() => {
|
||||||
|
if (modRef.current.ctrl || modRef.current.alt || modRef.current.shift) {
|
||||||
|
modRef.current = { ctrl: false, alt: false, shift: false };
|
||||||
|
setMods(modRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const toggleMod = useCallback((k: 'ctrl' | 'alt' | 'shift') => {
|
||||||
|
modRef.current = { ...modRef.current, [k]: !modRef.current[k] };
|
||||||
|
setMods(modRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Eine Taste (fertiges Keysym) senden — mit ggf. aktiven Modifiern.
|
||||||
|
const pressKey = useCallback((ks: number) => {
|
||||||
|
const m = activeMods();
|
||||||
|
if (m.length) { sendCombo(m, ks); clearMods(); } else sendKeysym(ks);
|
||||||
|
}, [activeMods, sendCombo, clearMods, sendKeysym]);
|
||||||
|
|
||||||
|
// Ein druckbares Zeichen senden — mit ggf. aktiven Modifiern (Strg+C etc.).
|
||||||
|
const pressChar = useCallback((cp: number) => {
|
||||||
|
const m = activeMods();
|
||||||
|
if (m.length) { sendCombo(m, cpToKeysym(cp)); clearMods(); }
|
||||||
|
else webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`);
|
||||||
|
}, [activeMods, sendCombo, clearMods]);
|
||||||
|
|
||||||
|
// Tastatur ein-/ausblenden. Oeffnen: blur→focus erzwingt das Aufklappen auch
|
||||||
|
// dann, wenn der TextInput noch fokussiert ist (Tastatur per Zurueck-Taste
|
||||||
|
// versteckt). Schliessen: Keyboard.dismiss(); den Button-Zustand setzt der
|
||||||
|
// keyboardDidShow/Hide-Listener — nicht hier —, damit er nie „haengen" bleibt.
|
||||||
|
const toggleKbd = useCallback(() => {
|
||||||
|
if (kbdOn) { Keyboard.dismiss(); }
|
||||||
|
else { kbdRef.current?.blur(); setTimeout(() => kbdRef.current?.focus(), 30); }
|
||||||
|
}, [kbdOn]);
|
||||||
|
|
||||||
|
// Druckbare Zeichen: Prefix-Diff des (wachsenden) Feldes → nur neu Getipptes an
|
||||||
|
// die VM. Loeschungen kommen ueber onKeyPress(Backspace), daher hier nur Inserts.
|
||||||
|
const onKbdChange = useCallback((e: NativeSyntheticEvent<TextInputChangeEventData>) => {
|
||||||
|
const text = e.nativeEvent.text || '';
|
||||||
|
const prev = bufRef.current;
|
||||||
|
let i = 0;
|
||||||
|
const min = Math.min(prev.length, text.length);
|
||||||
|
while (i < min && prev.charCodeAt(i) === text.charCodeAt(i)) i++;
|
||||||
|
for (const ch of text.slice(i)) { const cp = ch.codePointAt(0); if (cp) pressChar(cp); }
|
||||||
|
bufRef.current = text;
|
||||||
|
if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); }
|
||||||
|
}, [pressChar]);
|
||||||
|
|
||||||
|
// Sondertasten der Software-Tastatur: Backspace feuert auf Android zuverlaessig
|
||||||
|
// als keyPress; die Return-Taste (Haken) kommt als onSubmitEditing (s.u.).
|
||||||
|
const onKbdKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||||
|
const k = e.nativeEvent.key;
|
||||||
|
if (k === 'Backspace') pressKey(KEYSYM.Backspace);
|
||||||
|
else if (k === 'Enter') pressKey(KEYSYM.Enter);
|
||||||
|
}, [pressKey]);
|
||||||
|
|
||||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||||
let m: any;
|
let m: any;
|
||||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||||
@@ -84,14 +185,34 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
|||||||
keyboardDisplayRequiresUserAction={false}
|
keyboardDisplayRequiresUserAction={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM.
|
||||||
|
keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und
|
||||||
|
liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */}
|
||||||
|
<TextInput
|
||||||
|
ref={kbdRef}
|
||||||
|
style={styles.hiddenInput}
|
||||||
|
onChange={onKbdChange}
|
||||||
|
onKeyPress={onKbdKeyPress}
|
||||||
|
onSubmitEditing={() => pressKey(KEYSYM.Enter)}
|
||||||
|
keyboardType="visible-password"
|
||||||
|
returnKeyType="send"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
caretHidden
|
||||||
|
contextMenuHidden
|
||||||
|
multiline={false}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
||||||
{connected && (
|
{connected && (
|
||||||
<View style={styles.ctlBar}>
|
<View style={styles.ctlBar}>
|
||||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
|
<TouchableOpacity style={[styles.ctlBtn, keyBar && styles.ctlBtnOn]} onPress={() => setKeyBar(v => !v)} activeOpacity={0.7}>
|
||||||
<Text style={styles.ctlText}>⌨</Text>
|
<Text style={styles.ctlText}>Fn</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
|
<TouchableOpacity style={[styles.ctlBtn, kbdOn && styles.ctlBtnOn]} onPress={toggleKbd} activeOpacity={0.7}>
|
||||||
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
|
<Text style={styles.ctlText}>⌨</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
||||||
<Text style={styles.ctlText}>⤢</Text>
|
<Text style={styles.ctlText}>⤢</Text>
|
||||||
@@ -99,6 +220,26 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fn-Leiste — echte Steuertasten (oben, ueber der Software-Tastatur). */}
|
||||||
|
{connected && keyBar && (
|
||||||
|
<View style={styles.keyBar} pointerEvents="box-none">
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||||
|
<TouchableOpacity style={[styles.key, mods.ctrl && styles.keyOn]} onPress={() => toggleMod('ctrl')} activeOpacity={0.7}><Text style={styles.keyText}>Strg</Text></TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.key, mods.alt && styles.keyOn]} onPress={() => toggleMod('alt')} activeOpacity={0.7}><Text style={styles.keyText}>Alt</Text></TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.key, mods.shift && styles.keyOn]} onPress={() => toggleMod('shift')} activeOpacity={0.7}><Text style={styles.keyText}>Shift</Text></TouchableOpacity>
|
||||||
|
{NAV_KEYS.map(k => (
|
||||||
|
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||||
|
{F_KEYS.map(k => (
|
||||||
|
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity style={styles.key} onPress={() => ctl('cad')} activeOpacity={0.7}><Text style={styles.keyTextSm}>Strg+Alt+Entf</Text></TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!connected && (
|
{!connected && (
|
||||||
<View style={styles.overlay} pointerEvents="none">
|
<View style={styles.overlay} pointerEvents="none">
|
||||||
<Text style={styles.overlayText}>
|
<Text style={styles.overlayText}>
|
||||||
@@ -119,7 +260,7 @@ const styles = StyleSheet.create({
|
|||||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
ctlBar: {
|
ctlBar: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 34,
|
||||||
right: 8,
|
right: 8,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
@@ -135,8 +276,21 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
ctlBtnOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||||
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
||||||
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
||||||
|
// Fokussierbar (nicht display:none), aber aus dem Sichtfeld geschoben.
|
||||||
|
hiddenInput: { position: 'absolute', width: 1, height: 1, top: -100, left: -100, opacity: 0, padding: 0 },
|
||||||
|
keyBar: { position: 'absolute', top: 74, left: 0, right: 0, gap: 5 },
|
||||||
|
keyRow: { paddingHorizontal: 6, gap: 5, alignItems: 'center' },
|
||||||
|
key: {
|
||||||
|
backgroundColor: 'rgba(18,18,42,0.92)', borderColor: '#2A2A3E', borderWidth: 1,
|
||||||
|
borderRadius: 8, paddingHorizontal: 9, paddingVertical: 7, minWidth: 34,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
},
|
||||||
|
keyOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||||
|
keyText: { color: '#E0E0F0', fontSize: 13, fontWeight: '700' },
|
||||||
|
keyTextSm: { color: '#E0E0F0', fontSize: 10, fontWeight: '700' },
|
||||||
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
||||||
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,14 +149,23 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent = agent_factory()
|
# WICHTIG: agent.chat() ist ein SYNCHRONER, blockierender Aufruf (Proxy-
|
||||||
reply, _, _, _, _ = agent.chat(prompt, source="trigger")
|
# HTTP mit bis zu 24h Read-Timeout). NIEMALS direkt im async-Loop —
|
||||||
events = agent.pop_events()
|
# sonst friert ein einziger getriggerter Turn den GESAMTEN Brain ein
|
||||||
|
# (kein /health, kein weiterer Request). Wie der /chat-Pfad in den
|
||||||
|
# Executor auslagern, damit der Event-Loop frei bleibt.
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _run_turn():
|
||||||
|
a = agent_factory()
|
||||||
|
rep, *_rest = a.chat(prompt, source="trigger")
|
||||||
|
return rep, a.pop_events()
|
||||||
|
|
||||||
|
reply, events = await loop.run_in_executor(None, _run_turn)
|
||||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||||
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
|
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
|
||||||
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
|
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
|
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
||||||
|
|||||||
+29
-23
@@ -963,17 +963,21 @@ def _docker_gateway() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
def _ssh_host(*cmd: str, timeout: int = 25):
|
||||||
import subprocess
|
import subprocess
|
||||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
full = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
||||||
_ARIA_VM_HOST, "aria-vm", *[str(a) for a in args]]
|
_ARIA_VM_HOST, *[str(c) for c in cmd]]
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
r = subprocess.run(full, capture_output=True, text=True, timeout=timeout)
|
||||||
return r.returncode, r.stdout or "", r.stderr or ""
|
return r.returncode, r.stdout or "", r.stderr or ""
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return 1, "", str(exc)
|
return 1, "", str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
||||||
|
return _ssh_host("aria-vm", *args, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def _vm_running_names() -> set:
|
def _vm_running_names() -> set:
|
||||||
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
||||||
names = set()
|
names = set()
|
||||||
@@ -1074,11 +1078,13 @@ def project_vm_stop(project_id: str, name: str):
|
|||||||
|
|
||||||
@app.post("/projects/{project_id}/vms/{name}/screenshot")
|
@app.post("/projects/{project_id}/vms/{name}/screenshot")
|
||||||
def project_vm_screenshot(project_id: str, name: str):
|
def project_vm_screenshot(project_id: str, name: str):
|
||||||
"""Macht einen Screenshot der laufenden VM (aria-vm screenshot → PNG in
|
"""Macht einen Screenshot der laufenden VM und liefert ihn als Base64.
|
||||||
/shared/uploads) und liefert ihn als Base64 zurueck. So sieht Stefan den
|
|
||||||
VM-Bildschirm auch ohne Live-VNC (genau wie ARIA es beim Testen macht)."""
|
aria-vm schreibt das PNG ins VM-Verzeichnis (dem aria-User gehoerend — nicht
|
||||||
|
ins /root-Shared-Volume, wo der aria-User keinen Zugriff hat). Der Brain holt
|
||||||
|
die Datei danach per SSH (base64) — funktioniert unabhaengig von Volume-
|
||||||
|
Rechten. Zusaetzlich wird das PNG ins Projekt kopiert (Dateien-Panel)."""
|
||||||
import base64
|
import base64
|
||||||
import time as _t
|
|
||||||
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
|
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
|
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
|
||||||
@@ -1088,28 +1094,28 @@ def project_vm_screenshot(project_id: str, name: str):
|
|||||||
path = line.split("=", 1)[1].strip()
|
path = line.split("=", 1)[1].strip()
|
||||||
if not path:
|
if not path:
|
||||||
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
|
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
|
||||||
local = os.path.join("/shared/uploads", os.path.basename(path))
|
# PNG per SSH als Base64 holen (kein Shared-Volume noetig).
|
||||||
for _ in range(10): # kurze Bind-Mount-Latenz abfangen
|
rc2, b64, err2 = _ssh_host("base64", "-w0", path, timeout=20)
|
||||||
if os.path.isfile(local):
|
if rc2 != 0 or not b64.strip():
|
||||||
break
|
raise HTTPException(status_code=500, detail=f"Screenshot konnte nicht gelesen werden: {(err2 or 'leer').strip()[:200]}")
|
||||||
_t.sleep(0.2)
|
b64 = b64.strip()
|
||||||
if not os.path.isfile(local):
|
try:
|
||||||
raise HTTPException(status_code=500, detail=f"Screenshot-Datei nicht gefunden: {local}")
|
data = base64.b64decode(b64)
|
||||||
with open(local, "rb") as f:
|
except Exception as exc:
|
||||||
data = f.read()
|
raise HTTPException(status_code=500, detail=f"Base64 ungueltig: {exc}")
|
||||||
# Auch ins Projekt kopieren → taucht im Dateien-Panel auf.
|
fname = os.path.basename(path)
|
||||||
|
# Ins Projekt kopieren → taucht im Dateien-Panel auf.
|
||||||
proj_rel = ""
|
proj_rel = ""
|
||||||
try:
|
try:
|
||||||
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
|
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
|
||||||
os.makedirs(shots_dir, exist_ok=True)
|
os.makedirs(shots_dir, exist_ok=True)
|
||||||
with open(os.path.join(shots_dir, os.path.basename(local)), "wb") as f:
|
with open(os.path.join(shots_dir, fname), "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
proj_rel = "screenshots/" + os.path.basename(local)
|
proj_rel = "screenshots/" + fname
|
||||||
except Exception:
|
except Exception:
|
||||||
proj_rel = ""
|
proj_rel = ""
|
||||||
return {"ok": True, "name": name, "filename": os.path.basename(local),
|
return {"ok": True, "name": name, "filename": fname,
|
||||||
"projectPath": proj_rel,
|
"projectPath": proj_rel, "base64": b64}
|
||||||
"base64": base64.b64encode(data).decode("ascii")}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/conversation/stats")
|
@app.get("/conversation/stats")
|
||||||
|
|||||||
+115
-23
@@ -43,6 +43,25 @@ from openwakeword.model import Model as WakeWordModel
|
|||||||
|
|
||||||
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_gateway() -> str:
|
||||||
|
"""Docker-Gateway-IP (= Host-IP auf DIESEM Container-Netz, aria-net) aus
|
||||||
|
/proc/net/route. Genau die IP, an die der Brain QEMUs VNC bindet — im
|
||||||
|
Gegensatz zu host.docker.internal, das auf die Default-Bridge (docker0)
|
||||||
|
zeigt und daher die VM nicht trifft."""
|
||||||
|
try:
|
||||||
|
import socket as _sock
|
||||||
|
import struct as _struct
|
||||||
|
with open("/proc/net/route") as f:
|
||||||
|
for line in f.readlines()[1:]:
|
||||||
|
fields = line.strip().split()
|
||||||
|
if len(fields) >= 4 and fields[1] == "00000000" and int(fields[3], 16) & 2:
|
||||||
|
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────
|
# ── Logging ──────────────────────────────────────────────────
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -738,7 +757,10 @@ class ARIABridge:
|
|||||||
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
||||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||||
self._vnc_sessions: dict[str, dict] = {}
|
self._vnc_sessions: dict[str, dict] = {}
|
||||||
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
|
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
|
||||||
|
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
|
||||||
|
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
|
||||||
|
or _docker_gateway() or "host.docker.internal")
|
||||||
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
||||||
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
||||||
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
||||||
@@ -768,7 +790,8 @@ class ARIABridge:
|
|||||||
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
|
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
|
||||||
# Anweisung" beim file, einmal auf den Chat-Text).
|
# Anweisung" beim file, einmal auf den Chat-Text).
|
||||||
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
|
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
|
||||||
self._pending_files: list[tuple[str, str, str, int, int, int]] = []
|
# (file_path, name, type, kb, width, height, clientMsgId)
|
||||||
|
self._pending_files: list[tuple[str, str, str, int, int, int, str]] = []
|
||||||
self._pending_files_flush_task: Optional[asyncio.Task] = None
|
self._pending_files_flush_task: Optional[asyncio.Task] = None
|
||||||
# Projekt-Kontext der gerade gepufferten Anhaenge (aus dem file-Upload).
|
# Projekt-Kontext der gerade gepufferten Anhaenge (aus dem file-Upload).
|
||||||
# Wird beim Flush an send_to_core gegeben, damit Anhaenge im richtigen
|
# Wird beim Flush an send_to_core gegeben, damit Anhaenge im richtigen
|
||||||
@@ -1817,16 +1840,16 @@ class ARIABridge:
|
|||||||
return " ".join(parts) + " " + text
|
return " ".join(parts) + " " + text
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _build_pending_files_message(self, user_text: str) -> str:
|
def _build_pending_files_message(self, user_text: str, files: list) -> str:
|
||||||
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
|
"""Baut eine Anweisung an aria-core aus den uebergebenen Files + optionalem
|
||||||
User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
|
User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for fp, name, ftype, kb, w, h in self._pending_files:
|
for fp, name, ftype, kb, w, h, _cmid in files:
|
||||||
dim = f" {w}x{h}px" if (w and h) else ""
|
dim = f" {w}x{h}px" if (w and h) else ""
|
||||||
kind = "Bild" if ftype.startswith("image/") else "Datei"
|
kind = "Bild" if ftype.startswith("image/") else "Datei"
|
||||||
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
|
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
|
||||||
files_summary = "\n".join(parts)
|
files_summary = "\n".join(parts)
|
||||||
n = len(self._pending_files)
|
n = len(files)
|
||||||
anhang = "Anhang" if n == 1 else "Anhaenge"
|
anhang = "Anhang" if n == 1 else "Anhaenge"
|
||||||
if user_text:
|
if user_text:
|
||||||
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||||
@@ -1835,15 +1858,16 @@ class ARIABridge:
|
|||||||
f"Warte auf seine Anweisung was du damit tun sollst.")
|
f"Warte auf seine Anweisung was du damit tun sollst.")
|
||||||
|
|
||||||
async def _flush_pending_files_after(self, delay: float) -> None:
|
async def _flush_pending_files_after(self, delay: float) -> None:
|
||||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
|
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
||||||
aria-core senden ('warte auf Anweisung'-Variante)."""
|
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
if not self._pending_files:
|
if not self._pending_files:
|
||||||
return
|
return
|
||||||
text = self._build_pending_files_message("")
|
files = self._pending_files
|
||||||
|
text = self._build_pending_files_message("", files)
|
||||||
self._pending_files = []
|
self._pending_files = []
|
||||||
self._pending_files_flush_task = None
|
self._pending_files_flush_task = None
|
||||||
pid = self._pending_files_project_id
|
pid = self._pending_files_project_id
|
||||||
@@ -1851,23 +1875,48 @@ class ARIABridge:
|
|||||||
await self.send_to_core(text, source="app-file", project_id=pid)
|
await self.send_to_core(text, source="app-file", project_id=pid)
|
||||||
|
|
||||||
async def _flush_pending_files_with_text(self, user_text: str,
|
async def _flush_pending_files_with_text(self, user_text: str,
|
||||||
project_id: str = "") -> bool:
|
project_id: str = "",
|
||||||
|
client_msg_id: str = "") -> bool:
|
||||||
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
||||||
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
||||||
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
||||||
|
|
||||||
|
KORRELATION (Fix Queue-Bug): Files tragen dieselbe clientMsgId wie ihr
|
||||||
|
Text. Bei einer Queue gehen Files (fire-and-forget) und Text (ACK-
|
||||||
|
getrackt, ggf. verzoegert) auseinander — ohne Korrelation landeten die
|
||||||
|
Bilder beim falschen Text. Wir mergen darum NUR die Files mit passender
|
||||||
|
cmid; der Rest bleibt gepuffert fuer seine eigene Nachricht. Fallback
|
||||||
|
(Legacy-App ohne cmid an Files, oder cmid ohne Treffer): altes Verhalten
|
||||||
|
(alle Files mit diesem Text), damit nie ein Bild verloren geht.
|
||||||
|
|
||||||
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
||||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext
|
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
||||||
zurueck, damit Anhaenge im richtigen Projekt landen statt im Hauptchat."""
|
"""
|
||||||
if not self._pending_files:
|
if not self._pending_files:
|
||||||
return False
|
return False
|
||||||
|
cmid = (client_msg_id or "").strip()
|
||||||
|
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
||||||
|
if not matching:
|
||||||
|
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
||||||
|
matching = list(self._pending_files)
|
||||||
|
remaining: list = []
|
||||||
|
else:
|
||||||
|
remaining = [f for f in self._pending_files if f not in matching]
|
||||||
|
|
||||||
|
text = self._build_pending_files_message(user_text, matching)
|
||||||
|
self._pending_files = remaining
|
||||||
|
pid = (project_id or "").strip() or self._pending_files_project_id
|
||||||
|
# Flush-Timer neu setzen wenn noch Files anderer Nachrichten warten,
|
||||||
|
# sonst zuruecksetzen.
|
||||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||||
self._pending_files_flush_task.cancel()
|
self._pending_files_flush_task.cancel()
|
||||||
self._pending_files_flush_task = None
|
self._pending_files_flush_task = None
|
||||||
text = self._build_pending_files_message(user_text)
|
if remaining:
|
||||||
self._pending_files = []
|
self._pending_files_flush_task = asyncio.create_task(
|
||||||
pid = (project_id or "").strip() or self._pending_files_project_id
|
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||||
self._pending_files_project_id = ""
|
)
|
||||||
|
else:
|
||||||
|
self._pending_files_project_id = ""
|
||||||
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||||
# fertig ist (siehe chat-handler oben).
|
# fertig ist (siehe chat-handler oben).
|
||||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
||||||
@@ -1914,10 +1963,13 @@ class ARIABridge:
|
|||||||
url, data=payload, method="POST",
|
url, data=payload, method="POST",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
# 20 Min Timeout — lange Multi-Tool-Workflows (Karten,
|
# Timeout MUSS zum Proxy passen (der laesst ARIA bis 24h
|
||||||
# PDFs, viele curl-Calls) brauchen das. 5 Min waren chronisch
|
# rechnen). 1200s (20 Min) war zu knapp: lange Software-Dev-
|
||||||
# zu knapp und haben ARIA mitten in der Arbeit gekappt.
|
# Turns dauern laenger → die Bridge gab auf, die Antwort ging
|
||||||
with urllib.request.urlopen(req, timeout=1200) as resp:
|
# verloren (kein Bubble), der Kontext blieb auf 'running'
|
||||||
|
# haengen. Jetzt 24h (env BRAIN_CHAT_TIMEOUT_SEC).
|
||||||
|
_chat_timeout = float(os.environ.get("BRAIN_CHAT_TIMEOUT_SEC", "86400"))
|
||||||
|
with urllib.request.urlopen(req, timeout=_chat_timeout) as resp:
|
||||||
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return None, str(exc)
|
return None, str(exc)
|
||||||
@@ -2342,7 +2394,8 @@ class ARIABridge:
|
|||||||
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
||||||
# zwei separater send_to_core-Calls.
|
# zwei separater send_to_core-Calls.
|
||||||
merged = await self._flush_pending_files_with_text(
|
merged = await self._flush_pending_files_with_text(
|
||||||
text, project_id=str(payload.get("projectId") or ""))
|
text, project_id=str(payload.get("projectId") or ""),
|
||||||
|
client_msg_id=client_msg_id or "")
|
||||||
if merged:
|
if merged:
|
||||||
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
|
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
|
||||||
str(payload.get("projectId") or "") or "(main)", text[:80])
|
str(payload.get("projectId") or "") or "(main)", text[:80])
|
||||||
@@ -2384,6 +2437,20 @@ class ARIABridge:
|
|||||||
await self._emit_activity("idle", "", project_id=cancel_pid)
|
await self._emit_activity("idle", "", project_id=cancel_pid)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if msg_type == "interject":
|
||||||
|
# Zwischenruf: waehrend eines laufenden Turns eine Korrektur
|
||||||
|
# reinschieben — KEIN Abbruch, keine Queue. Geht an den Proxy-
|
||||||
|
# internen /interject, der die Message in den laufenden Subprozess
|
||||||
|
# des Kontexts schreibt (claude greift sie an der naechsten Tool-
|
||||||
|
# Grenze auf).
|
||||||
|
interject_pid = str(payload.get("projectId") or "")
|
||||||
|
interject_text = str(payload.get("text") or "")
|
||||||
|
logger.info("[rvs] Zwischenruf project=%s: '%s'",
|
||||||
|
interject_pid or "(main)", interject_text[:80])
|
||||||
|
if interject_text.strip():
|
||||||
|
await self._interject_proxy_for_project(interject_pid, interject_text)
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type == "audio_pcm":
|
elif msg_type == "audio_pcm":
|
||||||
# Audio-PCM geht direkt von XTTS-Bridge an die App.
|
# Audio-PCM geht direkt von XTTS-Bridge an die App.
|
||||||
# Die aria-bridge darf es NICHT rebroadcasten — sonst bekommt die App
|
# Die aria-bridge darf es NICHT rebroadcasten — sonst bekommt die App
|
||||||
@@ -2636,8 +2703,11 @@ class ARIABridge:
|
|||||||
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
|
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
|
||||||
file_name, e)
|
file_name, e)
|
||||||
|
|
||||||
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
|
# In Pending-Queue + Flush-Timer (anti-spam Buffering).
|
||||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
|
# clientMsgId mitpuffern → spaeterer Text-Flush ordnet die Datei
|
||||||
|
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
||||||
|
file_cmid = str(payload.get("clientMsgId") or "")
|
||||||
|
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
||||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||||
self._pending_files_flush_task.cancel()
|
self._pending_files_flush_task.cancel()
|
||||||
self._pending_files_flush_task = asyncio.create_task(
|
self._pending_files_flush_task = asyncio.create_task(
|
||||||
@@ -4079,6 +4149,28 @@ class ARIABridge:
|
|||||||
logger.info("[cancel] proxy /cancel project=%s: %s %s",
|
logger.info("[cancel] proxy /cancel project=%s: %s %s",
|
||||||
project_id or "(main)", status, body)
|
project_id or "(main)", status, body)
|
||||||
|
|
||||||
|
async def _interject_proxy_for_project(self, project_id: str, text: str) -> None:
|
||||||
|
"""Zwischenruf: schiebt eine User-Message in den laufenden Turn dieses
|
||||||
|
Kontexts (proxy-internes /interject) — ohne Abbruch. claude greift sie
|
||||||
|
an der naechsten Tool-Grenze auf."""
|
||||||
|
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/interject"
|
||||||
|
data = json.dumps({"projectId": project_id or "", "text": text}).encode("utf-8")
|
||||||
|
|
||||||
|
def _do_request():
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, method="POST", data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
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.info("[interject] proxy /interject project=%s: %s %s",
|
||||||
|
project_id or "(main)", status, body)
|
||||||
|
|
||||||
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False,
|
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False,
|
||||||
project_id: str = "") -> None:
|
project_id: str = "") -> None:
|
||||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||||
|
|||||||
+67
-1
@@ -198,7 +198,7 @@
|
|||||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||||
<span id="disk-banner-icon" style="font-size:18px;">⚠️</span>
|
<span id="disk-banner-icon" style="font-size:18px;">⚠️</span>
|
||||||
<span id="disk-banner-text" style="flex:1;min-width:200px;font-weight:600;"></span>
|
<span id="disk-banner-text" style="flex:1;min-width:200px;font-weight:600;"></span>
|
||||||
<button onclick="copyDiskCmd('safe')" class="btn secondary" style="padding:4px 10px;font-size:11px;" title="docker builder prune -a -f && docker image prune -a -f">
|
<button id="disk-clean-btn" onclick="runDiskCleanup('safe')" class="btn secondary" style="padding:4px 10px;font-size:11px;" title="Fuehrt aus: docker builder prune -a && docker image prune -a (ohne Volumes)">
|
||||||
Sicher aufraeumen
|
Sicher aufraeumen
|
||||||
</button>
|
</button>
|
||||||
<button onclick="document.getElementById('disk-banner-aggressive').style.display=(document.getElementById('disk-banner-aggressive').style.display==='none'?'flex':'none')"
|
<button onclick="document.getElementById('disk-banner-aggressive').style.display=(document.getElementById('disk-banner-aggressive').style.display==='none'?'flex':'none')"
|
||||||
@@ -216,6 +216,7 @@
|
|||||||
<div style="color:#FFAA55;">
|
<div style="color:#FFAA55;">
|
||||||
<b>Aggressiv</b> — zusaetzlich ungenutzte Volumes. <b>Nur wenn alle ARIA-Container laufen</b>, sonst riskierst du Daten-Verlust (Sessions, SSH-Keys, Shared):<br>
|
<b>Aggressiv</b> — zusaetzlich ungenutzte Volumes. <b>Nur wenn alle ARIA-Container laufen</b>, sonst riskierst du Daten-Verlust (Sessions, SSH-Keys, Shared):<br>
|
||||||
<code style="font-family:monospace;">docker system prune -a --volumes -f</code>
|
<code style="font-family:monospace;">docker system prune -a --volumes -f</code>
|
||||||
|
<button onclick="runDiskCleanup('aggressive')" class="btn secondary" style="padding:2px 8px;font-size:10px;margin-left:6px;border-color:#FF3B30;color:#FF3B30;">Jetzt ausfuehren</button>
|
||||||
<button onclick="copyDiskCmd('aggressive')" class="btn secondary" style="padding:2px 8px;font-size:10px;margin-left:6px;">Kopieren</button>
|
<button onclick="copyDiskCmd('aggressive')" class="btn secondary" style="padding:2px 8px;font-size:10px;margin-left:6px;">Kopieren</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,6 +329,7 @@
|
|||||||
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
||||||
</label>
|
</label>
|
||||||
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
|
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
|
||||||
|
<button class="btn secondary" onclick="interjectDiag('chat-input')" title="Zwischenruf — Korrektur in den laufenden Turn (kein Abbruch, keine Queue)" style="border-color:#FF9500;color:#FF9500;">📣</button>
|
||||||
<button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
|
<button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,6 +347,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="input-row" style="margin-top:8px;">
|
<div class="input-row" style="margin-top:8px;">
|
||||||
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
|
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
|
||||||
|
<button class="btn secondary" onclick="interjectDiag('chat-input-fs')" title="Zwischenruf — Korrektur in den laufenden Turn (kein Abbruch, keine Queue)" style="border-color:#FF9500;color:#FF9500;">📣</button>
|
||||||
<button class="btn" onclick="testRVSFS()">Senden</button>
|
<button class="btn" onclick="testRVSFS()">Senden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1585,9 +1588,34 @@
|
|||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
diagQueueStatus = d?.contexts || {};
|
diagQueueStatus = d?.contexts || {};
|
||||||
renderContextStrip();
|
renderContextStrip();
|
||||||
|
reconcileDiagStates();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selbstheilung: haengt ein Kontext lokal auf 'running', obwohl der Brain
|
||||||
|
// ihn NICHT als busy meldet, ist der Turn (z.B. durch einen Bridge-Timeout)
|
||||||
|
// ohne Antwort-Bubble abgebrochen — der Automat blieb sonst ewig 'running'
|
||||||
|
// (Folge-Nachrichten wandern in die Queue, obwohl ARIA idle ist; frueher
|
||||||
|
// half nur Ctrl+R, was die Queue-Nachricht verlor). Nach 2 aufeinander-
|
||||||
|
// folgenden not-busy-Polls (~4s Karenz gegen das Sende-Fenster) schalten
|
||||||
|
// wir die Queue weiter: angestellte Nachricht geht automatisch raus, sonst
|
||||||
|
// idle.
|
||||||
|
const diagStuckCounts = {};
|
||||||
|
function reconcileDiagStates() {
|
||||||
|
const ctxs = diagQueueStatus || {};
|
||||||
|
for (const pid of Object.keys(diagCtxStates)) {
|
||||||
|
if (diagCtxStates[pid] !== 'running') { diagStuckCounts[pid] = 0; continue; }
|
||||||
|
const busy = !!(ctxs[pid || '__main__'] && ctxs[pid || '__main__'].busy);
|
||||||
|
if (busy) { diagStuckCounts[pid] = 0; continue; }
|
||||||
|
diagStuckCounts[pid] = (diagStuckCounts[pid] || 0) + 1;
|
||||||
|
if (diagStuckCounts[pid] >= 2) {
|
||||||
|
diagStuckCounts[pid] = 0;
|
||||||
|
console.warn('[diag] Kontext', pid || '(main)', 'haengt auf running, Brain idle → Queue weiterschalten');
|
||||||
|
advanceDiagQueue(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshDiagProjectsCache() {
|
async function refreshDiagProjectsCache() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/brain/projects/list?include_archived=false');
|
const r = await fetch('/api/brain/projects/list?include_archived=false');
|
||||||
@@ -1737,6 +1765,20 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'disk_cleanup') {
|
||||||
|
const btn = document.getElementById('disk-clean-btn');
|
||||||
|
if (msg.status === 'running') {
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
|
||||||
|
} else if (msg.status === 'done') {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Sicher aufraeumen'; }
|
||||||
|
alert('Aufgeraeumt: ' + (msg.freed || '0 MB') + ' frei\n(' + (msg.steps || []).join(', ') + ')');
|
||||||
|
} else if (msg.status === 'error') {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Sicher aufraeumen'; }
|
||||||
|
alert('Aufraeumen fehlgeschlagen: ' + (msg.error || ''));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'mode' && msg.payload) {
|
if (msg.type === 'mode' && msg.payload) {
|
||||||
// Bridge hat den Modus geaendert (evtl. von anderer App/Diagnostic) — UI syncen
|
// Bridge hat den Modus geaendert (evtl. von anderer App/Diagnostic) — UI syncen
|
||||||
const mode = (msg.payload.mode || '').toLowerCase();
|
const mode = (msg.payload.mode || '').toLowerCase();
|
||||||
@@ -2244,6 +2286,18 @@
|
|||||||
diagCtxStates[pid] = 'running';
|
diagCtxStates[pid] = 'running';
|
||||||
renderDiagQueue();
|
renderDiagQueue();
|
||||||
}
|
}
|
||||||
|
// Zwischenruf: waehrend ARIA arbeitet eine Korrektur in den laufenden Turn
|
||||||
|
// schieben — NICHT anstellen, NICHT abbrechen.
|
||||||
|
function interjectDiag(inputId) {
|
||||||
|
const input = document.getElementById(inputId || 'chat-input');
|
||||||
|
const text = (input.value || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
send({ action: 'interject', text, projectId: focusedContextId });
|
||||||
|
addChat('sent', '📣 Zwischenruf: ' + text, 'interject', { projectId: focusedContextId });
|
||||||
|
diagDrafts[focusedContextId] = '';
|
||||||
|
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
function advanceDiagQueue(pid) {
|
function advanceDiagQueue(pid) {
|
||||||
const q = diagCtxQueues[pid] || [];
|
const q = diagCtxQueues[pid] || [];
|
||||||
if (q.length === 0) { diagCtxStates[pid] = 'idle'; renderDiagQueue(); return; }
|
if (q.length === 0) { diagCtxStates[pid] = 'idle'; renderDiagQueue(); return; }
|
||||||
@@ -6720,6 +6774,18 @@
|
|||||||
banner.style.display = 'block';
|
banner.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fuehrt das Aufraeumen WIRKLICH aus (Server-seitig via Docker-API), statt
|
||||||
|
// nur den Befehl zu kopieren.
|
||||||
|
function runDiskCleanup(variant) {
|
||||||
|
const aggressive = variant === 'aggressive';
|
||||||
|
const q = aggressive
|
||||||
|
? 'AGGRESSIV aufraeumen? Loescht zusaetzlich ungenutzte Volumes — nur wenn ALLE ARIA-Container laufen, sonst Datenverlust!'
|
||||||
|
: 'Sicher aufraeumen? Loescht Build-Cache + ungenutzte Images (keine Volumes, keine Daten gehen verloren).';
|
||||||
|
if (!confirm(q)) return;
|
||||||
|
const btn = document.getElementById('disk-clean-btn');
|
||||||
|
if (btn && !aggressive) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
|
||||||
|
send({ action: 'disk_cleanup', variant });
|
||||||
|
}
|
||||||
function copyDiskCmd(variant) {
|
function copyDiskCmd(variant) {
|
||||||
const cmd = variant === 'aggressive'
|
const cmd = variant === 'aggressive'
|
||||||
? 'docker system prune -a --volumes -f'
|
? 'docker system prune -a --volumes -f'
|
||||||
|
|||||||
+81
-3
@@ -1559,6 +1559,70 @@ function dockerExec(containerName, cmd) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST gegen die Docker-Daemon-API (via gemountetem Socket). Fuer prune-
|
||||||
|
// Endpoints — die geben SpaceReclaimed (Bytes) zurueck.
|
||||||
|
function dockerApiPost(apiPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({
|
||||||
|
socketPath: "/var/run/docker.sock",
|
||||||
|
path: apiPath,
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Content-Length": 0 },
|
||||||
|
}, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => data += c);
|
||||||
|
res.on("end", () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
try { resolve(JSON.parse(data || "{}")); } catch { resolve({}); }
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Docker API ${apiPath}: HTTP ${res.statusCode} — ${String(data).slice(0, 200)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Sicher aufraeumen": Build-Cache + ungenutzte Images prunen — OHNE Volumes
|
||||||
|
// (keine Daten weg). "aggressive": zusaetzlich gestoppte Container + ungenutzte
|
||||||
|
// Volumes (kann Daten kosten → nur auf ausdrueckliche Wahl). Fuehrt es WIRKLICH
|
||||||
|
// aus (frueher kopierte der Button nur den Befehl in die Zwischenablage).
|
||||||
|
async function handleDiskCleanup(clientWs, variant) {
|
||||||
|
const aggressive = variant === "aggressive";
|
||||||
|
const send = (o) => { try { clientWs.send(JSON.stringify(o)); } catch (_) {} };
|
||||||
|
send({ type: "disk_cleanup", status: "running", variant });
|
||||||
|
log("warn", "server", `Disk-Cleanup gestartet (${aggressive ? "aggressive" : "safe"})`);
|
||||||
|
try {
|
||||||
|
let reclaimed = 0;
|
||||||
|
const steps = [];
|
||||||
|
const bp = await dockerApiPost("/build/prune?all=true");
|
||||||
|
reclaimed += (bp.SpaceReclaimed || 0);
|
||||||
|
steps.push("Build-Cache");
|
||||||
|
// dangling=false → ALLE ungenutzten Images (nicht nur dangling).
|
||||||
|
// Docker-API-Filterformat: map[string][]string.
|
||||||
|
const imgFilter = encodeURIComponent(JSON.stringify({ dangling: ["false"] }));
|
||||||
|
const ip = await dockerApiPost("/images/prune?filters=" + imgFilter);
|
||||||
|
reclaimed += (ip.SpaceReclaimed || 0);
|
||||||
|
steps.push("ungenutzte Images");
|
||||||
|
if (aggressive) {
|
||||||
|
const cp = await dockerApiPost("/containers/prune");
|
||||||
|
reclaimed += (cp.SpaceReclaimed || 0);
|
||||||
|
steps.push("gestoppte Container");
|
||||||
|
const vp = await dockerApiPost("/volumes/prune");
|
||||||
|
reclaimed += (vp.SpaceReclaimed || 0);
|
||||||
|
steps.push("ungenutzte Volumes");
|
||||||
|
}
|
||||||
|
const mb = (reclaimed / (1024 * 1024));
|
||||||
|
const freed = mb >= 1024 ? (mb / 1024).toFixed(2) + " GB" : mb.toFixed(0) + " MB";
|
||||||
|
log("info", "server", `Disk-Cleanup fertig: ${freed} frei (${steps.join(", ")})`);
|
||||||
|
send({ type: "disk_cleanup", status: "done", variant, reclaimedBytes: reclaimed, freed, steps });
|
||||||
|
} catch (err) {
|
||||||
|
log("error", "server", `Disk-Cleanup fehlgeschlagen: ${err.message}`);
|
||||||
|
send({ type: "disk_cleanup", status: "error", variant, error: String(err && err.message || err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hilfsfunktionen ─────────────────────────────────────
|
// ── Hilfsfunktionen ─────────────────────────────────────
|
||||||
|
|
||||||
function waitForMessage(ws, timeoutMs) {
|
function waitForMessage(ws, timeoutMs) {
|
||||||
@@ -2455,6 +2519,16 @@ wss.on("connection", (ws) => {
|
|||||||
} else if (msg.action === "test_rvs") {
|
} else if (msg.action === "test_rvs") {
|
||||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||||
sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
|
sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
|
||||||
|
} else if (msg.action === "interject") {
|
||||||
|
// Zwischenruf: in den laufenden Turn schieben (kein Abbruch, keine
|
||||||
|
// Queue) → RVS interject → Bridge → Proxy /interject.
|
||||||
|
const t = String(msg.text || "");
|
||||||
|
if (t.trim()) {
|
||||||
|
sendToRVS_raw({ type: "interject", payload: { projectId: msg.projectId || "", text: t }, timestamp: Date.now() });
|
||||||
|
log("info", "server", "Zwischenruf an RVS (project=" + (msg.projectId || "(main)") + "): " + t.slice(0, 60));
|
||||||
|
}
|
||||||
|
} else if (msg.action === "disk_cleanup") {
|
||||||
|
handleDiskCleanup(ws, msg.variant === "aggressive" ? "aggressive" : "safe");
|
||||||
} else if (msg.action === "reconnect_gateway") {
|
} else if (msg.action === "reconnect_gateway") {
|
||||||
connectGateway();
|
connectGateway();
|
||||||
} else if (msg.action === "reconnect_rvs") {
|
} else if (msg.action === "reconnect_rvs") {
|
||||||
@@ -2499,14 +2573,18 @@ wss.on("connection", (ws) => {
|
|||||||
});
|
});
|
||||||
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
||||||
} else if (msg.action === "cancel_request") {
|
} else if (msg.action === "cancel_request") {
|
||||||
// Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
|
// Laufende Anfrage abbrechen — ECHTER Cancel: RVS cancel_request (hard)
|
||||||
log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");
|
// an die Bridge, die den Proxy-/cancel-all Side-Channel anruft und den
|
||||||
|
// laufenden claude-Subprozess killt. Das alte `openclaw doctor --fix`
|
||||||
|
// zielte auf den Container aria-core, den es nicht mehr gibt — es
|
||||||
|
// beendete den Run nie (ARIA lief munter weiter).
|
||||||
|
log("warn", "server", "Anfrage abgebrochen — cancel_request (hard) an Bridge/Proxy");
|
||||||
pendingMessageTime = 0;
|
pendingMessageTime = 0;
|
||||||
watchdogWarned = false;
|
watchdogWarned = false;
|
||||||
watchdogFixAttempted = false;
|
watchdogFixAttempted = false;
|
||||||
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
||||||
broadcast({ type: "agent_activity", activity: "idle" });
|
broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-cancel" }, timestamp: Date.now() });
|
||||||
} else if (msg.action === "aria_panic_stop") {
|
} else if (msg.action === "aria_panic_stop") {
|
||||||
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
||||||
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
||||||
|
|||||||
+1
-5
@@ -11,11 +11,7 @@ services:
|
|||||||
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
|
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
|
||||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
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/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 &&
|
cp /proxy-patches/manager.js $$DIST/subprocess/manager.js &&
|
||||||
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
|
|
||||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
|
||||||
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
|
|
||||||
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
|
|
||||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.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/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||||
|
|||||||
@@ -147,16 +147,20 @@ cmd_screenshot() {
|
|||||||
local d; d="$(vm_dir "${name}")"
|
local d; d="$(vm_dir "${name}")"
|
||||||
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
||||||
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
|
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
|
||||||
mkdir -p "${SHOT_DIR}"
|
# Standard: ins VM-Verzeichnis schreiben (dem aria-User gehoerend) — NICHT
|
||||||
|
# nach /root/... (da kommt der aria-User nicht hin). Der Brain holt das PNG
|
||||||
|
# danach per SSH (base64). Ueberschreibbar via ARIA_VM_SHOT_DIR.
|
||||||
|
local out_dir="${ARIA_VM_SHOT_DIR:-${d}}"
|
||||||
|
mkdir -p "${out_dir}"
|
||||||
local ts; ts="$(date +%s)"
|
local ts; ts="$(date +%s)"
|
||||||
local ppm="${d}/shot-${ts}.ppm"
|
local ppm="${d}/shot-${ts}.ppm"
|
||||||
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
local out="${SHOT_DIR}/${name}-${ts}.png"
|
local out="${out_dir}/${name}-${ts}.png"
|
||||||
if command -v convert >/dev/null; then
|
if command -v convert >/dev/null; then
|
||||||
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
||||||
else
|
else
|
||||||
out="${SHOT_DIR}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
out="${out_dir}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
||||||
fi
|
fi
|
||||||
echo "screenshot=${out}"
|
echo "screenshot=${out}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Claude Code CLI Subprocess Manager — ARIA-Patch
|
||||||
|
*
|
||||||
|
* Basis: claude-max-api-proxy dist/subprocess/manager.js, plus die bisher per
|
||||||
|
* sed in docker-compose.yml eingespielten Anpassungen (dangerously-skip-
|
||||||
|
* permissions, system-prompt, 24h-Timeout, Prompt via stdin) — hier fest im
|
||||||
|
* File, damit der groessere Zwischenruf-Umbau nicht per sed gefrickelt werden
|
||||||
|
* muss. Wird per `cp` ueber die npm-Version gelegt (siehe docker-compose.yml).
|
||||||
|
*
|
||||||
|
* ZWISCHENRUF (interject): Statt den Prompt als Text zu schreiben und stdin
|
||||||
|
* sofort zu schliessen (--print/text), laeuft claude jetzt im
|
||||||
|
* `--input-format stream-json`-Modus. Der initiale Prompt geht als
|
||||||
|
* stream-json User-Message rein, stdin bleibt OFFEN — so kann waehrend des
|
||||||
|
* laufenden Turns per sendMessage() eine weitere User-Message reingeschoben
|
||||||
|
* werden, die claude an der naechsten Tool-Grenze aufgreift (kein Abbruch).
|
||||||
|
* Bei 'result' (Turn fertig) wird stdin geschlossen, damit claude sauber
|
||||||
|
* beendet und die HTTP-Response (in routes.js an 'close' gebunden) rausgeht.
|
||||||
|
*/
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
||||||
|
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
||||||
|
export class ClaudeSubprocess extends EventEmitter {
|
||||||
|
process = null;
|
||||||
|
buffer = "";
|
||||||
|
timeoutId = null;
|
||||||
|
isKilled = false;
|
||||||
|
_stdinClosed = false;
|
||||||
|
/**
|
||||||
|
* Start the Claude CLI subprocess with the given prompt
|
||||||
|
*/
|
||||||
|
async start(prompt, options) {
|
||||||
|
const args = this.buildArgs(prompt, options);
|
||||||
|
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// Use spawn() for security - no shell interpretation
|
||||||
|
this.process = spawn("claude", args, {
|
||||||
|
cwd: options.cwd || process.cwd(),
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
// Set timeout
|
||||||
|
this.timeoutId = setTimeout(() => {
|
||||||
|
if (!this.isKilled) {
|
||||||
|
this.isKilled = true;
|
||||||
|
this.process?.kill("SIGTERM");
|
||||||
|
this.emit("error", new Error(`Request timed out after ${timeout}ms`));
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
// Handle spawn errors (e.g., claude not found)
|
||||||
|
this.process.on("error", (err) => {
|
||||||
|
this.clearTimeout();
|
||||||
|
if (err.message.includes("ENOENT")) {
|
||||||
|
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// stdin BLEIBT OFFEN: initialen Prompt als stream-json User-
|
||||||
|
// Message schreiben; spaetere Zwischenrufe kommen via
|
||||||
|
// sendMessage(). Geschlossen wird bei 'result' (s. processBuffer).
|
||||||
|
this._writeUserMessage(prompt);
|
||||||
|
// Falls stdin (z.B. EPIPE) frueh stirbt: nicht crashen.
|
||||||
|
this.process.stdin?.on("error", () => {});
|
||||||
|
console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`);
|
||||||
|
// Parse JSON stream from stdout
|
||||||
|
this.process.stdout?.on("data", (chunk) => {
|
||||||
|
const data = chunk.toString();
|
||||||
|
console.error(`[Subprocess] Received ${data.length} bytes of stdout`);
|
||||||
|
this.buffer += data;
|
||||||
|
this.processBuffer();
|
||||||
|
});
|
||||||
|
// Capture stderr for debugging
|
||||||
|
this.process.stderr?.on("data", (chunk) => {
|
||||||
|
const errorText = chunk.toString().trim();
|
||||||
|
if (errorText) {
|
||||||
|
// Don't emit as error unless it's actually an error
|
||||||
|
// Claude CLI may write debug info to stderr
|
||||||
|
console.error("[Subprocess stderr]:", errorText.slice(0, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Handle process close
|
||||||
|
this.process.on("close", (code) => {
|
||||||
|
console.error(`[Subprocess] Process closed with code: ${code}`);
|
||||||
|
this.clearTimeout();
|
||||||
|
// Process any remaining buffer
|
||||||
|
if (this.buffer.trim()) {
|
||||||
|
this.processBuffer();
|
||||||
|
}
|
||||||
|
this.emit("close", code);
|
||||||
|
});
|
||||||
|
// Resolve immediately since we're streaming
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.clearTimeout();
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Build CLI arguments array
|
||||||
|
*/
|
||||||
|
buildArgs(prompt, options) {
|
||||||
|
const args = [
|
||||||
|
"--print", // Non-interactive mode
|
||||||
|
"--output-format",
|
||||||
|
"stream-json", // JSON streaming output
|
||||||
|
"--verbose", // Required for stream-json
|
||||||
|
"--include-partial-messages", // Enable streaming chunks
|
||||||
|
"--input-format",
|
||||||
|
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
||||||
|
"--model",
|
||||||
|
options.model, // Model alias (opus/sonnet/haiku)
|
||||||
|
"--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode",
|
||||||
|
];
|
||||||
|
if (options.sessionId) {
|
||||||
|
args.push("--session-id", options.sessionId);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
||||||
|
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
||||||
|
*/
|
||||||
|
_writeUserMessage(text) {
|
||||||
|
const p = this.process;
|
||||||
|
if (!p || !p.stdin || p.stdin.destroyed || this._stdinClosed)
|
||||||
|
return false;
|
||||||
|
try {
|
||||||
|
p.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: String(text) } }) + "\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Zwischenruf: waehrend eines laufenden Turns eine weitere User-Message
|
||||||
|
* reinschieben. claude greift sie an der naechsten Tool-Grenze auf, ohne
|
||||||
|
* den Turn abzubrechen. Kein Effekt, wenn stdin schon geschlossen ist
|
||||||
|
* (Turn praktisch fertig) — dann ist der Zwischenruf schlicht zu spaet.
|
||||||
|
*/
|
||||||
|
sendMessage(text) {
|
||||||
|
return this._writeUserMessage(text);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* stdin schliessen → claude beendet den stream-json-Input und exit't.
|
||||||
|
*/
|
||||||
|
_closeStdin() {
|
||||||
|
if (this._stdinClosed)
|
||||||
|
return;
|
||||||
|
this._stdinClosed = true;
|
||||||
|
try {
|
||||||
|
this.process?.stdin?.end();
|
||||||
|
}
|
||||||
|
catch (_) { }
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Process the buffer and emit parsed messages
|
||||||
|
*/
|
||||||
|
processBuffer() {
|
||||||
|
const lines = this.buffer.split("\n");
|
||||||
|
this.buffer = lines.pop() || ""; // Keep incomplete line
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(trimmed);
|
||||||
|
this.emit("message", message);
|
||||||
|
if (isContentDelta(message)) {
|
||||||
|
// Emit content delta for streaming
|
||||||
|
this.emit("content_delta", message);
|
||||||
|
}
|
||||||
|
else if (isAssistantMessage(message)) {
|
||||||
|
this.emit("assistant", message);
|
||||||
|
}
|
||||||
|
else if (isResultMessage(message)) {
|
||||||
|
this.emit("result", message);
|
||||||
|
// Turn fertig → stdin schliessen, sonst wartet claude im
|
||||||
|
// stream-json-Input auf weitere Messages und der Prozess
|
||||||
|
// (und damit die HTTP-Response) haengt fuer immer.
|
||||||
|
this._closeStdin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Non-JSON output, emit as raw
|
||||||
|
this.emit("raw", trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Clear the timeout timer
|
||||||
|
*/
|
||||||
|
clearTimeout() {
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId);
|
||||||
|
this.timeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Kill the subprocess
|
||||||
|
*/
|
||||||
|
kill(signal = "SIGTERM") {
|
||||||
|
if (!this.isKilled && this.process) {
|
||||||
|
this.isKilled = true;
|
||||||
|
this.clearTimeout();
|
||||||
|
this.process.kill(signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Check if the process is still running
|
||||||
|
*/
|
||||||
|
isRunning() {
|
||||||
|
return this.process !== null && !this.isKilled && this.process.exitCode === null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Verify that Claude CLI is installed and accessible
|
||||||
|
*/
|
||||||
|
export async function verifyClaude() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn("claude", ["--version"], { stdio: "pipe" });
|
||||||
|
let output = "";
|
||||||
|
proc.stdout?.on("data", (chunk) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
proc.on("error", () => {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
error: "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ ok: true, version: output.trim() });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
error: "Claude CLI returned non-zero exit code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Check if Claude CLI is authenticated
|
||||||
|
*/
|
||||||
|
export async function verifyAuth() {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=manager.js.map
|
||||||
@@ -25,6 +25,13 @@ const MODEL_MAP = {
|
|||||||
"opus": "opus",
|
"opus": "opus",
|
||||||
"sonnet": "sonnet",
|
"sonnet": "sonnet",
|
||||||
"haiku": "haiku",
|
"haiku": "haiku",
|
||||||
|
"fable": "fable",
|
||||||
|
"claude-fable-5": "fable",
|
||||||
|
"claude-code-cli/fable": "fable",
|
||||||
|
// Volle aktuelle IDs (falls die App/Diagnostic sie mal direkt setzt)
|
||||||
|
"claude-opus-5": "opus",
|
||||||
|
"claude-sonnet-5": "sonnet",
|
||||||
|
"claude-haiku-4-5": "haiku",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function extractModel(model) {
|
export function extractModel(model) {
|
||||||
|
|||||||
+46
-4
@@ -514,12 +514,18 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
|
|||||||
// Datei, greifen die eingebauten Defaults; die Datei wird dann einmalig mit
|
// Datei, greifen die eingebauten Defaults; die Datei wird dann einmalig mit
|
||||||
// diesen Defaults angelegt, damit es was zu editieren gibt.
|
// diesen Defaults angelegt, damit es was zu editieren gibt.
|
||||||
const MODELS_FILE = process.env.ARIA_MODELS_FILE || "/shared/config/models.json";
|
const MODELS_FILE = process.env.ARIA_MODELS_FILE || "/shared/config/models.json";
|
||||||
|
// Tier-Aliase als id (opus/sonnet/haiku/fable) — die CLI loest sie automatisch
|
||||||
|
// auf die AKTUELLE Version des Tiers auf (Stand 2026-07: fable→Fable 5,
|
||||||
|
// opus→Opus 5, sonnet→Sonnet 5, haiku→Haiku 4.5). So bleibt die Liste
|
||||||
|
// versions-robust; die display_name-Texte nur bei Tier-Wechsel anpassen.
|
||||||
const DEFAULT_MODELS = [
|
const DEFAULT_MODELS = [
|
||||||
{ id: "claude-sonnet-4", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
|
{ id: "fable", tier: "fable", display_name: "Fable (aktuell: Fable 5)",
|
||||||
|
description: "Staerkstes Modell — fuer die haertesten Aufgaben (Software-Entwicklung, lange Agent-Laeufe)." },
|
||||||
|
{ id: "opus", tier: "opus", display_name: "Opus (aktuell: Opus 5)",
|
||||||
|
description: "Sehr schlau, schneller als Fable — fuer schwere/lange Aufgaben." },
|
||||||
|
{ id: "sonnet", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
|
||||||
description: "Schnell & gut — Standard fuer den Alltag." },
|
description: "Schnell & gut — Standard fuer den Alltag." },
|
||||||
{ id: "claude-opus-4", tier: "opus", display_name: "Opus (aktuell: Opus 4.8)",
|
{ id: "haiku", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
|
||||||
description: "Langsamer, aber am schlausten — fuer schwere/lange Aufgaben." },
|
|
||||||
{ id: "claude-haiku-4", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
|
|
||||||
description: "Sehr schnell & guenstig, kleinerer Kontext — fuer einfache Tasks." },
|
description: "Sehr schnell & guenstig, kleinerer Kontext — fuer einfache Tasks." },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -621,6 +627,28 @@ function _cancelByProject(projectId) {
|
|||||||
return { killed, requestIds: ids, projectId: pid };
|
return { killed, requestIds: ids, projectId: pid };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zwischenruf: schiebt eine User-Message in den/die laufenden Subprozess(e)
|
||||||
|
// eines Kontexts, OHNE sie zu killen. claude greift sie an der naechsten Tool-
|
||||||
|
// Grenze auf (stream-json-Input, s. manager.js). Kein Treffer / stdin schon
|
||||||
|
// zu (Turn quasi fertig) → delivered=0.
|
||||||
|
function _interjectByProject(projectId, text) {
|
||||||
|
const pid = String(projectId || "");
|
||||||
|
const ids = [];
|
||||||
|
let delivered = 0;
|
||||||
|
for (const [id, entry] of Array.from(_activeSubprocesses)) {
|
||||||
|
if (entry.projectId !== pid) continue;
|
||||||
|
try {
|
||||||
|
if (typeof entry.subprocess.sendMessage === "function" && entry.subprocess.sendMessage(text)) {
|
||||||
|
delivered++;
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[aria-interject] sendMessage failed for", id, e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { delivered, requestIds: ids, projectId: pid };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const internalServer = http.createServer((req, res) => {
|
const internalServer = http.createServer((req, res) => {
|
||||||
if (req.method === "POST" && req.url === "/cancel-all") {
|
if (req.method === "POST" && req.url === "/cancel-all") {
|
||||||
@@ -645,6 +673,20 @@ try {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.method === "POST" && req.url === "/interject") {
|
||||||
|
// Body: {projectId, text}. Zwischenruf in den laufenden Turn.
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (c) => { raw += c; if (raw.length > 65536) req.destroy(); });
|
||||||
|
req.on("end", () => {
|
||||||
|
let projectId = "", text = "";
|
||||||
|
try { const b = JSON.parse(raw || "{}"); projectId = String(b.projectId || ""); text = String(b.text || ""); } catch (_) {}
|
||||||
|
const result = text ? _interjectByProject(projectId, text) : { delivered: 0, requestIds: [], projectId };
|
||||||
|
console.warn("[aria-interject] /interject project=%s — delivered %d", projectId || "(main)", result.delivered);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, ...result }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === "GET" && req.url === "/health") {
|
if (req.method === "GET" && req.url === "/health") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
|
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||||
"update_check", "update_available", "update_download", "update_data",
|
"update_check", "update_available", "update_download", "update_data",
|
||||||
"agent_activity", "cancel_request",
|
"agent_activity", "cancel_request", "interject",
|
||||||
"audio_pcm",
|
"audio_pcm",
|
||||||
"file_from_aria",
|
"file_from_aria",
|
||||||
"container_restart",
|
"container_restart",
|
||||||
|
|||||||
Reference in New Issue
Block a user