diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index 3daba4b..29d1437 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -176,6 +176,18 @@ export interface ProjectStatus { projects: Project[]; } +/** QEMU-VM eines Projekts (Registry + Live-Status). */ +export interface ProjectVm { + name: string; + arch: string; + iso?: string; + vnc_display: number; + mem: number; + running?: boolean; + vnc_port?: number; + created_at?: number; +} + /** Queue-Status pro Kontext — was gerade arbeitet, was wartet. * Key "__main__" = Hauptchat, sonst project_id. */ export interface QueueContextStatus { @@ -626,6 +638,23 @@ export const brainApi = { return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`); }, + // ── QEMU-VMs pro Projekt ───────────────────────────────────────── + listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> { + return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 }); + }, + addProjectVm(projectId: string, body: { name: string; arch?: string; iso?: string; vnc_display?: number; mem?: number; create_disk?: boolean; size?: string }): Promise { + return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { method: 'POST', body, timeoutMs: 30000 }); + }, + removeProjectVm(projectId: string, name: string, purge = false): Promise<{ ok: boolean }> { + return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}?purge=${purge ? 'true' : 'false'}`, { method: 'DELETE' }); + }, + bootProjectVm(projectId: string, name: string): Promise<{ ok: boolean; vnc_port: number; output: string }> { + return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/boot`, { method: 'POST', timeoutMs: 45000 }); + }, + stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> { + return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 }); + }, + /** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */ setProjectHidden(projectId: string, hidden: boolean): Promise { return _send(`/projects/${encodeURIComponent(projectId)}`, { diff --git a/android/src/workspace/WorkspaceDeck.tsx b/android/src/workspace/WorkspaceDeck.tsx index 3349feb..6cccbd2 100644 --- a/android/src/workspace/WorkspaceDeck.tsx +++ b/android/src/workspace/WorkspaceDeck.tsx @@ -16,7 +16,7 @@ import { useWorkspaceLayout } from './useWorkspaceLayout'; import WorkspaceDock from './WorkspaceDock'; import ChatTile from './tiles/ChatTile'; import CodeEditorTile from './tiles/CodeEditorTile'; -import VncTile from './tiles/VncTile'; +import DesktopTile from './tiles/DesktopTile'; interface Props { projectId: string; @@ -58,7 +58,7 @@ const WorkspaceDeck: React.FC = ({ projectId, panels, badges }) => { switch (id) { case 'chat': return ; case 'editor': return ; - case 'vnc': return ; + case 'vnc': return ; default: return null; } }; diff --git a/android/src/workspace/tiles/DesktopTile.tsx b/android/src/workspace/tiles/DesktopTile.tsx new file mode 100644 index 0000000..3c7aeee --- /dev/null +++ b/android/src/workspace/tiles/DesktopTile.tsx @@ -0,0 +1,154 @@ +/** + * DesktopTile — das Desktop-Panel eines Code-Projekts. + * + * Zeigt die (pro Projekt gefuehrte) QEMU-VM-Liste: leer, bis ARIA per + * vm_register eine VM eintraegt. Pro VM: Start / Stop / Verbinden. „Verbinden" + * oeffnet die noVNC-Ansicht (VncTile) fuer den VNC-Port dieser VM. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import brainApi, { ProjectVm } from '../../services/brainApi'; +import VncTile from './VncTile'; + +interface Props { + projectId: string; + focused: boolean; +} + +const DesktopTile: React.FC = ({ projectId, focused }) => { + const [vms, setVms] = useState([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt + const [connected, setConnected] = useState(null); + + const load = useCallback(() => { + setLoading(true); setErr(''); + brainApi.listProjectVms(projectId) + .then(r => setVms(r.vms || [])) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setLoading(false)); + }, [projectId]); + + useEffect(() => { + if (focused && !connected) load(); + }, [focused, projectId, connected, load]); + + const boot = useCallback((vm: ProjectVm) => { + setBusy(vm.name); + brainApi.bootProjectVm(projectId, vm.name) + .then(() => load()) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setBusy('')); + }, [projectId, load]); + + const stop = useCallback((vm: ProjectVm) => { + setBusy(vm.name); + brainApi.stopProjectVm(projectId, vm.name) + .then(() => load()) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setBusy('')); + }, [projectId, load]); + + if (!focused) { + return ( + + 🖥️ + Desktop + Panel öffnen für VM-Liste + + ); + } + + // Verbunden → noVNC-Ansicht der VM + Zurück-Leiste. + if (connected) { + return ( + + + setConnected(null)} style={styles.barBtn}> + ‹ VMs + + {connected.name} · :{connected.vnc_display} + + + + ); + } + + return ( + + + Virtuelle Maschinen + + + + {loading && vms.length === 0 ? ( + + ) : err ? ( + {err} + ) : vms.length === 0 ? ( + + Noch keine VM in diesem Projekt.{'\n'} + Sag ARIA z.B. „bau eine QEMU-VM zum Testen" — sie registriert sie hier, + dann kannst du sie starten und verbinden. + + ) : ( + vms.map(vm => { + const isBusy = busy === vm.name; + return ( + + + + {vm.name} + + {vm.arch} · Display :{vm.vnc_display} · {vm.running ? 'läuft' : 'gestoppt'} + + + {isBusy ? ( + + ) : vm.running ? ( + <> + setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}> + Verbinden + + stop(vm)} style={[styles.vmBtn, { borderColor: '#E55C5C' }]}> + Stop + + + ) : ( + boot(vm)} style={[styles.vmBtn, { borderColor: '#34C759' }]}> + Start + + )} + + + ); + }) + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#0D0D1A' }, + placeholder: { flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center' }, + icon: { fontSize: 64, marginBottom: 16 }, + text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, + sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, + bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 }, + barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 }, + barBtn: { paddingHorizontal: 10, paddingVertical: 4 }, + barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' }, + empty: { color: '#8888AA', fontSize: 13, lineHeight: 20, textAlign: 'center', marginTop: 24 }, + err: { color: '#FF6E6E', fontSize: 13, marginTop: 16 }, + vmRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', borderRadius: 10, padding: 12, marginBottom: 8 }, + vmName: { color: '#E0E0F0', fontSize: 15, fontWeight: '700' }, + vmMeta: { color: '#8888AA', fontSize: 12, marginTop: 3 }, + vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' }, + vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 }, + vmBtnText: { fontSize: 12, fontWeight: '700' }, +}); + +export default DesktopTile; diff --git a/android/src/workspace/tiles/VncTile.tsx b/android/src/workspace/tiles/VncTile.tsx index 08caefe..eefad01 100644 --- a/android/src/workspace/tiles/VncTile.tsx +++ b/android/src/workspace/tiles/VncTile.tsx @@ -16,9 +16,10 @@ import { NOVNC_HTML } from '../assets/novncHtml'; interface Props { projectId: string; focused: boolean; + port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1) } -const VncTile: React.FC = ({ projectId, focused }) => { +const VncTile: React.FC = ({ projectId, focused, port = 5901 }) => { const webRef = useRef(null); const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle'); const unsubDataRef = useRef void)>(null); @@ -46,7 +47,7 @@ const VncTile: React.FC = ({ projectId, focused }) => { const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`; webRef.current?.injectJavaScript(js); }); - desktop.openVnc(projectId); + desktop.openVnc(projectId, port); } else if (m.event === 'vnc_send') { desktop.sendInput(m.b64); } else if (m.event === 'vnc_close') { @@ -55,7 +56,7 @@ const VncTile: React.FC = ({ projectId, focused }) => { if (m.state === 'connected') setStatus('connected'); else if (m.state === 'disconnected') setStatus('disconnected'); } - }, [projectId]); + }, [projectId, port]); if (!focused) { return ( diff --git a/aria-brain/agent.py b/aria-brain/agent.py index d91b9a6..a6af380 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -37,6 +37,7 @@ import triggers as triggers_mod import watcher as watcher_mod import oauth as oauth_mod import projects as projects_mod +import project_vms as project_vms_mod BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") # SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b). @@ -1161,6 +1162,38 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "vm_register", + "description": ( + "Traegt eine QEMU-VM in die VM-Liste des AKTUELLEN Projekts ein, " + "damit sie in Stefans Desktop-Panel (Cockpit) erscheint und er sie " + "starten/stoppen/verbinden kann. Rufe das auf, NACHDEM Du mit aria-vm " + "eine VM gebaut/gebootet hast (z.B. bei einem OS-Bau-Projekt). " + "vnc_display bestimmt den VNC-Port (Port = 5900+display)." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "VM-Name wie bei aria-vm (a-z0-9_-)."}, + "arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips."}, + "iso": {"type": "string", "description": "optional: Boot-ISO-Pfad auf dem Host."}, + "vnc_display": {"type": "integer", "description": "VNC-Display (Default 1 → Port 5901)."}, + "mem": {"type": "integer", "description": "RAM in MB (Default 1024)."}, + }, + "required": ["name", "arch"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "vm_list", + "description": "Listet die registrierten QEMU-VMs des aktuellen Projekts (mit Lauf-Status).", + "parameters": {"type": "object", "properties": {}}, + }, + }, ] @@ -2797,6 +2830,29 @@ class Agent: return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat." if name in ("satellite_list", "satellite_devices", "satellite_command"): return self._dispatch_satellite(name, arguments) + if name == "vm_register": + pid = (project_id or "").strip() + if not pid: + return "Kein aktives Projekt — VMs werden pro Projekt gefuehrt. Erst ein Projekt betreten." + try: + vm = project_vms_mod.add_vm( + pid, (arguments.get("name") or "").strip(), + (arguments.get("arch") or "i386").strip(), + (arguments.get("iso") or "").strip(), + int(arguments.get("vnc_display") or 1), + int(arguments.get("mem") or 1024), + ) + except (ValueError, TypeError) as exc: + return f"FEHLER: {exc}" + return (f"OK — VM '{vm['name']}' ({vm['arch']}, Display :{vm['vnc_display']}) " + f"in Projekt '{pid}' registriert. Erscheint in Stefans Desktop-Panel.") + if name == "vm_list": + pid = (project_id or "").strip() + vms = project_vms_mod.list_vms(pid) + if not vms: + return f"Projekt '{pid or 'Hauptchat'}' hat noch keine registrierten VMs." + return "VMs:\n" + "\n".join( + f"- {v['name']} ({v.get('arch')}, Display :{v.get('vnc_display', 1)})" for v in vms) return f"Unbekanntes Tool: {name}" except Exception as exc: logger.exception("Tool '%s' fehlgeschlagen", name) diff --git a/aria-brain/main.py b/aria-brain/main.py index b36695f..7df53e2 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -39,6 +39,7 @@ import background as background_mod import oauth as oauth_mod import seed_rules as seed_rules_mod import projects as projects_mod +import project_vms as project_vms_mod logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logger = logging.getLogger("aria-brain") @@ -894,6 +895,98 @@ def project_file(project_id: str, path: str): return {"projectId": project_id, "path": path, "content": content} +# ── QEMU-VMs pro Projekt ──────────────────────────────────────────── +# Registry (project_vms) + echter Start/Stop via `aria-vm` auf dem Host (SSH +# aria-wohnung). Das Desktop-Panel der App zeigt pro Projekt die Liste. +_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung") + + +def _ssh_aria_vm(*args: str, timeout: int = 25): + import subprocess + cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8", + _ARIA_VM_HOST, "aria-vm", *[str(a) for a in args]] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout or "", r.stderr or "" + except Exception as exc: + return 1, "", str(exc) + + +def _vm_running_names() -> set: + rc, out, _err = _ssh_aria_vm("list", timeout=15) + names = set() + if rc == 0: + for line in out.splitlines(): + parts = line.split() + if parts and "laeuft" in line: + names.add(parts[0]) + return names + + +class VmAddBody(BaseModel): + name: str + arch: str = "i386" + iso: str = "" + vnc_display: int = 1 + mem: int = 1024 + create_disk: bool = False + size: str = "10G" + + +@app.get("/projects/{project_id}/vms") +def project_vms_list(project_id: str): + vms = [dict(v) for v in project_vms_mod.list_vms(project_id)] + running = _vm_running_names() + for v in vms: + v["running"] = v.get("name") in running + v["vnc_port"] = 5900 + int(v.get("vnc_display", 1)) + return {"projectId": project_id, "vms": vms} + + +@app.post("/projects/{project_id}/vms") +def project_vm_add(project_id: str, body: VmAddBody): + try: + vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso, + body.vnc_display, body.mem) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if body.create_disk: + rc, out, err = _ssh_aria_vm("create", body.name, body.arch, body.size) + vm["create_result"] = out.strip() or err.strip() + vm["create_ok"] = (rc == 0) + return vm + + +@app.delete("/projects/{project_id}/vms/{name}") +def project_vm_remove(project_id: str, name: str, purge: bool = False): + ok = project_vms_mod.remove_vm(project_id, name) + if not ok: + raise HTTPException(status_code=404, detail=f"VM '{name}' nicht in Projekt {project_id}") + if purge: + _ssh_aria_vm("rm", name) + return {"ok": True, "name": name} + + +@app.post("/projects/{project_id}/vms/{name}/boot") +def project_vm_boot(project_id: str, name: str): + vm = project_vms_mod.get_vm(project_id, name) + if not vm: + raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden") + args = ["boot", name, "--vnc-display", str(vm.get("vnc_display", 1)), + "--mem", str(vm.get("mem", 1024))] + if vm.get("iso"): + args += ["--iso", vm["iso"]] + rc, out, err = _ssh_aria_vm(*args, timeout=40) + return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)), + "output": (out.strip() or err.strip())[:500]} + + +@app.post("/projects/{project_id}/vms/{name}/stop") +def project_vm_stop(project_id: str, name: str): + rc, out, err = _ssh_aria_vm("stop", name, timeout=25) + return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]} + + @app.get("/conversation/stats") def conversation_stats(): return conversation().stats() diff --git a/aria-brain/project_vms.py b/aria-brain/project_vms.py new file mode 100644 index 0000000..2068473 --- /dev/null +++ b/aria-brain/project_vms.py @@ -0,0 +1,86 @@ +""" +project_vms — Registry der QEMU-VMs PRO PROJEKT. + +Persistenz: /shared/config/project_vms.json → { project_id: [ {vm}, ... ] }. +Eine VM = {name, arch, iso, vnc_display, mem, created_at, updated_at}. Der echte +Start/Stop laeuft ueber `aria-vm` auf dem Host (SSH aria-wohnung, siehe main.py); +diese Datei haelt nur die Zuordnung VM ↔ Projekt + die Startparameter, damit die +Liste im Desktop-Panel der App pro Projekt erscheint (auch wenn leer). +""" + +from __future__ import annotations + +import json +import os +import re +import time +from pathlib import Path +from typing import Optional + +VMS_FILE = Path(os.environ.get("PROJECT_VMS_FILE", "/shared/config/project_vms.json")) +NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,40}$") +VALID_ARCH = {"x86_64", "amd64", "i386", "i686", "x86", "arm", "aarch64", + "mips", "mipsel", "mips64", "ppc", "ppc64", "riscv64", "sparc"} + + +def _load() -> dict: + if not VMS_FILE.exists(): + return {} + try: + data = json.loads(VMS_FILE.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save(data: dict) -> None: + VMS_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = VMS_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(VMS_FILE) + + +def list_vms(project_id: str) -> list[dict]: + return _load().get(project_id or "", []) + + +def get_vm(project_id: str, name: str) -> Optional[dict]: + for v in list_vms(project_id): + if v.get("name") == name: + return v + return None + + +def add_vm(project_id: str, name: str, arch: str, iso: str = "", + vnc_display: int = 1, mem: int = 1024) -> dict: + if not NAME_RE.match(name or ""): + raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)") + if arch not in VALID_ARCH: + raise ValueError(f"Unbekannte Architektur: {arch!r}") + data = _load() + lst = data.setdefault(project_id or "", []) + now = int(time.time()) + for v in lst: + if v.get("name") == name: + v.update({"arch": arch, "iso": iso, "vnc_display": int(vnc_display), + "mem": int(mem), "updated_at": now}) + _save(data) + return v + vm = {"name": name, "arch": arch, "iso": iso, "vnc_display": int(vnc_display), + "mem": int(mem), "created_at": now, "updated_at": now} + lst.append(vm) + _save(data) + return vm + + +def remove_vm(project_id: str, name: str) -> bool: + data = _load() + lst = data.get(project_id or "") + if not lst: + return False + new = [v for v in lst if v.get("name") != name] + if len(new) == len(lst): + return False + data[project_id or ""] = new + _save(data) + return True diff --git a/aria-brain/seed_rules.py b/aria-brain/seed_rules.py index a6fb14a..071c583 100644 --- a/aria-brain/seed_rules.py +++ b/aria-brain/seed_rules.py @@ -428,6 +428,11 @@ SEED_RULES: List[dict] = [ "Stefan mit [FILE:] schicken).\n" " - `aria-vm list` / `aria-vm stop ` / `aria-vm rm `.\n" "\n" + "WICHTIG: Nachdem Du eine VM gebaut/gebootet hast, registriere sie mit " + "`vm_register(name, arch, vnc_display, ...)` — dann erscheint sie in Stefans " + "Desktop-Panel im Cockpit, wo er sie starten/stoppen/verbinden kann. Ohne " + "vm_register bleibt seine VM-Liste leer, obwohl die VM laeuft.\n" + "\n" "Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in " "Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die " "VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "