diff --git a/android/src/workspace/assets/editorHtml.ts b/android/src/workspace/assets/editorHtml.ts new file mode 100644 index 0000000..6411fbb --- /dev/null +++ b/android/src/workspace/assets/editorHtml.ts @@ -0,0 +1,167 @@ +/** + * editorHtml — selbstenthaltener Live-Code-Editor fuer die WebView (offline, + * kein CDN/Bundler). Eine transparente + +`; diff --git a/android/src/workspace/tiles/CodeEditorTile.tsx b/android/src/workspace/tiles/CodeEditorTile.tsx index 92203ab..65fa364 100644 --- a/android/src/workspace/tiles/CodeEditorTile.tsx +++ b/android/src/workspace/tiles/CodeEditorTile.tsx @@ -1,30 +1,128 @@ /** - * CodeEditorTile — Live-Code-Editor (CodeMirror in einer WebView). - * Platzhalter fuer Commit 4; die CodeMirror-Bridge folgt in Commit 5. + * CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts). + * + * Zeigt live, was ARIA in diesem Projekt schreibt (aus dem codeFile-Spiegel) + * und laesst Stefan selbst editieren — Aenderungen gehen als code_file_edit + * zurueck an die Bridge. Datei-Tabs oben zum Umschalten. */ -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import codeFile, { CodeFileState } from '../../services/codeFile'; +import { EDITOR_HTML } from '../assets/editorHtml'; interface Props { projectId: string; } -const CodeEditorTile: React.FC = () => { +const CodeEditorTile: React.FC = ({ projectId }) => { + const webRef = useRef(null); + const [files, setFiles] = useState(() => codeFile.getFiles(projectId)); + const [currentPath, setCurrentPath] = useState(files[0]?.path ?? null); + + const readyRef = useRef(false); + const currentPathRef = useRef(currentPath); + currentPathRef.current = currentPath; + + const sendToWeb = useCallback((payload: Record) => { + const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`; + webRef.current?.injectJavaScript(js); + }, []); + + const loadFileIntoEditor = useCallback((path: string | null) => { + if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; } + const f = codeFile.getFile(projectId, path); + sendToWeb({ cmd: 'setContent', content: f?.content ?? '', language: f?.language ?? 'text', version: f?.version ?? 0 }); + }, [projectId, sendToWeb]); + + // Projektwechsel: Dateiliste + Auswahl neu. + useEffect(() => { + const list = codeFile.getFiles(projectId); + setFiles(list); + setCurrentPath((prev) => (prev && list.some((f) => f.path === prev) ? prev : list[0]?.path ?? null)); + }, [projectId]); + + // Eingehende Updates aus dem Spiegel. + useEffect(() => { + return codeFile.subscribe((u) => { + if ((u.projectId || '') !== (projectId || '')) return; + setFiles(codeFile.getFiles(projectId)); + // Noch keine Datei gewaehlt → diese oeffnen. + if (!currentPathRef.current) { setCurrentPath(u.path); return; } + if (u.path !== currentPathRef.current) return; + if (!readyRef.current) return; + if (u.patch) { + sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version }); + } else { + sendToWeb({ cmd: 'setContent', content: u.content ?? '', language: u.language, version: u.version }); + } + }); + }, [projectId, sendToWeb]); + + // Datei-Auswahl gewechselt → in den Editor laden (falls WebView bereit). + useEffect(() => { + if (readyRef.current) loadFileIntoEditor(currentPath); + }, [currentPath, loadFileIntoEditor]); + + const onMessage = useCallback((e: WebViewMessageEvent) => { + let m: any; + try { m = JSON.parse(e.nativeEvent.data); } catch { return; } + if (m.event === 'ready') { + readyRef.current = true; + loadFileIntoEditor(currentPathRef.current); + } else if (m.event === 'onEditFromUser') { + const path = currentPathRef.current; + if (!path) return; + codeFile.sendEdit(projectId, path, { from: m.from, to: m.to, insert: m.insert }, m.fullText, m.version); + } + }, [projectId, loadFileIntoEditor]); + return ( - 📝 - Code-Editor - Wird geladen … + + {files.length === 0 ? ( + Noch keine Datei + ) : ( + + {files.map((f) => { + const active = f.path === currentPath; + const name = f.path.split('/').pop() || f.path; + return ( + setCurrentPath(f.path)} style={[styles.tab, active && styles.tabActive]}> + {name} + + ); + })} + + )} + + ); }; const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' }, - icon: { fontSize: 64, marginBottom: 16 }, - text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, - sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, + container: { flex: 1, backgroundColor: '#0D0D1A' }, + tabsRow: { height: 40, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1, justifyContent: 'center' }, + tabs: { alignItems: 'center', paddingHorizontal: 6 }, + noFiles: { color: '#9090B0', fontSize: 13, paddingHorizontal: 12 }, + tab: { paddingHorizontal: 12, paddingVertical: 6, marginHorizontal: 3, borderRadius: 12, backgroundColor: '#0D0D1A', maxWidth: 180 }, + tabActive: { backgroundColor: '#0096FF' }, + tabText: { color: '#9090B0', fontSize: 12, fontWeight: '600' }, + tabTextActive: { color: '#FFFFFF' }, + web: { flex: 1, backgroundColor: '#0D0D1A' }, }); export default CodeEditorTile; diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index cca3f61..22bc3fc 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -3351,6 +3351,19 @@ class ARIABridge: logger.info("[rvs] flux-bridge -> %s", state) return + elif msg_type == "code_file_edit": + # Stefan hat im Live-Editor getippt → Datei im Projekt-Arbeits- + # verzeichnis (/shared/projects//) mit dem Volltext ueberschreiben. + # ARIA sieht die Aenderung beim naechsten Read. + pid = str(payload.get("projectId") or "") + rel = (payload.get("path") or "").strip() + full_text = payload.get("fullText") + if rel and isinstance(full_text, str): + ok = self._write_project_file(pid, rel, full_text) + logger.info("[rvs] code_file_edit %s/%s (%d chars) → %s", + pid or "main", rel, len(full_text), "ok" if ok else "abgelehnt") + return + elif msg_type == "config_request": # Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice- # Config — passiert wenn sie sich connected, weil sie sonst die @@ -4192,6 +4205,33 @@ class ARIABridge: "timestamp": int(time.time() * 1000), })) await _send_response(writer, 200, {"ok": True}) + elif method == "POST" and path == "/internal/code-file": + # Vom Proxy-Hook gefeuert wenn ARIA in einem Code-Projekt eine + # Datei schreibt/aendert. Wir spiegeln das als RVS code_file an + # die App (Live-Code-Editor). payload: {projectId, path, + # language?, content?, patch?, version?}. + try: + data = json.loads(body.decode("utf-8", "ignore")) + except Exception as exc: + await _send_response(writer, 400, {"error": f"bad json: {exc}"}) + return + fpath = (data.get("path") or "").strip() + if not fpath: + await _send_response(writer, 400, {"error": "path erforderlich"}) + return + asyncio.create_task(self._send_to_rvs({ + "type": "code_file", + "payload": { + "projectId": str(data.get("projectId") or ""), + "path": fpath, + "language": data.get("language") or "", + "content": data.get("content"), + "patch": data.get("patch"), + "version": data.get("version") or 0, + }, + "timestamp": int(time.time() * 1000), + })) + await _send_response(writer, 200, {"ok": True}) elif method == "POST" and path == "/internal/flux-generate": # Vom Brain (flux_generate-Tool) gefeuert. Wir routen den # Render-Request via RVS an die flux-bridge (Gamebox), @@ -4301,6 +4341,28 @@ class ARIABridge: except Exception: logger.exception("[bridge] Internal HTTP-Listener konnte nicht starten") + def _write_project_file(self, project_id: str, rel_path: str, content: str) -> bool: + """Schreibt content nach /shared/projects//. + + Pfad-sicher: rel_path darf nicht aus dem Projekt-Basisverzeichnis + ausbrechen (kein .., kein absoluter Pfad). Legt Verzeichnisse an. + """ + try: + base = Path("/shared/projects") / (project_id or "main") + base = base.resolve() + target = (base / rel_path).resolve() + if base != target and base not in target.parents: + logger.warning("[code_file] Pfad ausserhalb Projekt abgelehnt: %s", rel_path) + return False + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_suffix(target.suffix + ".tmp") + tmp.write_text(content, encoding="utf-8") + tmp.replace(target) + return True + except Exception as exc: + logger.warning("[code_file] Schreiben fehlgeschlagen %s/%s: %s", project_id, rel_path, exc) + return False + async def _delete_chat_message(self, ts: int) -> dict: """Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation, broadcastet chat_message_deleted via RVS. diff --git a/proxy-patches/routes.js b/proxy-patches/routes.js index a6ac67c..49bcb35 100644 --- a/proxy-patches/routes.js +++ b/proxy-patches/routes.js @@ -28,6 +28,43 @@ const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL || "http://aria-bridge:8090/internal/agent-activity"; const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL || "http://aria-bridge:8090/internal/agent-stream"; +const CODE_FILE_HOOK_URL = process.env.ARIA_CODE_FILE_HOOK_URL + || "http://aria-bridge:8090/internal/code-file"; + +// Code-Projekte leben unter /shared/projects// (Volume in proxy + +// bridge + brain gemountet). Schreibt/aendert ARIA hier eine Datei, spiegeln +// wir den Volltext live in den Code-Editor der App. Nur Dateien unter diesem +// Praefix — ARIAs sonstige Datei-Ops (Skills, Configs) bleiben unberuehrt. +const PROJECTS_ROOT = "/shared/projects/"; +const CODE_FILE_MAX_BYTES = 512 * 1024; + +/** Zerlegt einen absoluten Pfad unter /shared/projects// → {pid, rel} + * oder null wenn er nicht darunter liegt. */ +function _parseProjectPath(filePath) { + if (typeof filePath !== "string" || !filePath.startsWith(PROJECTS_ROOT)) return null; + const rest = filePath.slice(PROJECTS_ROOT.length); + const slash = rest.indexOf("/"); + if (slash <= 0) return null; + return { pid: rest.slice(0, slash), rel: rest.slice(slash + 1) }; +} + +/** Liest die (frisch geschriebene) Datei und pusht sie als code_file an die + * Bridge. Fire-and-forget, fail-open. */ +function _emitCodeFile(filePath) { + try { + const parsed = _parseProjectPath(filePath); + if (!parsed || !parsed.rel) return; + const st = fs.statSync(filePath); + if (!st.isFile() || st.size > CODE_FILE_MAX_BYTES) return; + const content = fs.readFileSync(filePath, "utf8"); + _postJson(CODE_FILE_HOOK_URL, { + projectId: parsed.pid, + path: parsed.rel, + content, + version: Date.now(), + }); + } catch (_) { /* fail-open */ } +} // Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten // hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen @@ -153,6 +190,9 @@ function _attachIdleWatchdog(subprocess, requestId) { * - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream */ function _attachToolHook(subprocess, requestId, projectId) { + // tool_use_id → file_path fuer Write/Edit, damit wir beim (erfolgreichen) + // tool_result die frisch geschriebene Datei aus /shared lesen koennen. + const _pendingFileWrites = new Map(); subprocess.on("assistant", (message) => { try { const blocks = message?.message?.content || []; @@ -160,6 +200,10 @@ function _attachToolHook(subprocess, requestId, projectId) { if (!b) continue; if (b.type === "tool_use") { if (b.name) _emitToolEvent(b.name, projectId); + if ((b.name === "Write" || b.name === "Edit" || b.name === "MultiEdit") + && b.id && b.input && typeof b.input.file_path === "string") { + _pendingFileWrites.set(b.id, b.input.file_path); + } const inputStr = b.input ? JSON.stringify(b.input) : ""; const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS); _emitStreamEvent(requestId, "tool_use", { @@ -203,6 +247,11 @@ function _attachToolHook(subprocess, requestId, projectId) { truncatedBytes: out.truncatedBytes, isError: b.is_error === true, }); + // Write/Edit erfolgreich → Datei live in den Code-Editor spiegeln. + if (b.tool_use_id && b.is_error !== true && _pendingFileWrites.has(b.tool_use_id)) { + _emitCodeFile(_pendingFileWrites.get(b.tool_use_id)); + _pendingFileWrites.delete(b.tool_use_id); + } } } } catch (_) { /* fail-open */ }