feat: Workspace-Umbau Schritt 3 — Backend-Kanaele + code/desktop-Services

Verdrahtet die neuen Kanaele "dunkel" (noch ohne UI):
- rvs/server.js: ALLOWED_TYPES um code_file/code_file_edit, check_desktop/
  desktop_status und vnc_open/close/data/input erweitert (Base64-in-JSON-Relay
  wie audio_pcm, kein Binaer-Handling noetig).
- brainApi.ts: Project.kind ('code'|'chat') + desktop_url; ChatScreen publiziert
  die Kinds in den projectFocus-Spiegel.
- services/codeFile.ts: Live-Spiegel der Code-Dateien (content + Deltas) mit
  Ruckkanal code_file_edit fuer eigene Edits.
- services/desktop.ts: Desktop-Status + VNC-RFB-Tunnel (vnc_open/close/input/
  data) durch RVS, Session = Projekt-ID.

tsc clean, rvs/server.js syntaktisch OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:40:17 +02:00
co-authored by Claude Opus 4.8
parent 80b534cbab
commit 20c527c8ed
5 changed files with 120 additions and 1 deletions
+6 -1
View File
@@ -526,8 +526,13 @@ const ChatScreen: React.FC = () => {
brainApi.listProjects(true)
.then(list => {
const map: Record<string, string> = {};
for (const p of list) map[p.id] = p.name;
const kinds: Record<string, 'code' | 'chat'> = {};
for (const p of list) {
map[p.id] = p.name;
kinds[p.id] = p.kind === 'code' ? 'code' : 'chat';
}
setProjectNameById(prev => ({ ...prev, ...map }));
projectFocus.setKinds(kinds);
})
.catch(() => {});
};
+6
View File
@@ -162,6 +162,12 @@ export interface Project {
updated_at: number;
last_activity_at: number;
turn_count: number;
// Workspace: 'code' blendet Editor-/VNC-Kacheln ein. ARIA setzt das selbst
// via set_project_kind; fehlt/undefined = 'chat' (nur Chat-Kachel).
kind?: 'code' | 'chat';
// Optionale absolute noVNC-URL (falls der Desktop direkt erreichbar ist,
// sonst laeuft der VNC-Stream als RFB-Bytes durch RVS).
desktop_url?: string;
}
export interface ProjectStatus {
Binary file not shown.
+102
View File
@@ -0,0 +1,102 @@
/**
* desktop — Desktop-/VNC-Anbindung fuer Code-Projekte.
*
* Zwei Aufgaben:
* 1. Verfuegbarkeit: `check_desktop` triggert die Bridge, `desktop_status`
* meldet zurueck ob eine QEMU-VNC laeuft (und ggf. eine direkte URL).
* 2. VNC-Tunnel: der noVNC-Client in der App-WebView spricht kein eigenes
* WebSocket, sondern schickt RFB-Bytes als `vnc_input` (Base64) ueber RVS;
* die Bridge oeffnet die TCP-Verbindung zu QEMU (host:5901) und streamt die
* Antwort als `vnc_data` zurueck. Base64-in-JSON wie audio_pcm.
*
* Eine Session = ein Desktop; wir nutzen die Projekt-ID als Session-Key (leer =
* 'main'). Muster wie services/rvs.ts (Singleton mit Listener-Listen).
*/
import rvs, { RVSMessage } from './rvs';
export interface DesktopStatus {
available: boolean;
session: string;
/** optionale direkte noVNC-URL (falls Host direkt erreichbar) */
url?: string;
message?: string;
}
type StatusSub = (s: DesktopStatus) => void;
type VncDataSub = (b64: string) => void;
const DEFAULT_VNC_PORT = 5901;
const sessionOf = (projectId: string) => projectId || 'main';
class DesktopService {
private status: DesktopStatus = { available: false, session: '' };
private statusSubs: StatusSub[] = [];
private vncDataSubs: VncDataSub[] = [];
private currentSession = '';
constructor() {
rvs.onMessage((m) => this.onMessage(m));
}
private onMessage(m: RVSMessage): void {
const p = (m.payload || {}) as any;
if (m.type === 'desktop_status') {
this.status = {
available: !!p.available,
session: p.session || '',
url: typeof p.url === 'string' ? p.url : undefined,
message: p.message,
};
const s = this.status;
this.statusSubs.forEach((cb) => cb(s));
} else if (m.type === 'vnc_data') {
if (this.currentSession && p.session && p.session !== this.currentSession) return;
const b64 = typeof p.b64 === 'string' ? p.b64 : '';
if (b64) this.vncDataSubs.forEach((cb) => cb(b64));
}
}
getStatus(): DesktopStatus {
return this.status;
}
subscribeStatus(cb: StatusSub): () => void {
this.statusSubs.push(cb);
cb(this.status);
return () => { this.statusSubs = this.statusSubs.filter((s) => s !== cb); };
}
/** Bridge fragen, ob fuer dieses Projekt ein QEMU-Desktop laeuft. */
requestCheck(projectId: string, port: number = DEFAULT_VNC_PORT): void {
rvs.send('check_desktop', { projectId: projectId || '', session: sessionOf(projectId), port });
}
/** VNC-Tunnel oeffnen — Bridge verbindet TCP zu QEMU. */
openVnc(projectId: string, port: number = DEFAULT_VNC_PORT): string {
const session = sessionOf(projectId);
this.currentSession = session;
rvs.send('vnc_open', { session, port });
return session;
}
closeVnc(): void {
if (this.currentSession) rvs.send('vnc_close', { session: this.currentSession });
this.currentSession = '';
}
/** RFB-Bytes (Base64) aus der noVNC-WebView an die Bridge weiterreichen. */
sendInput(b64: string): void {
if (!this.currentSession) return;
rvs.send('vnc_input', { session: this.currentSession, b64 });
}
/** Listener fuer eingehende RFB-Bytes (Base64) — die noVNC-WebView. */
onVncData(cb: VncDataSub): () => void {
this.vncDataSubs.push(cb);
return () => { this.vncDataSubs = this.vncDataSubs.filter((s) => s !== cb); };
}
}
const desktop = new DesktopService();
export default desktop;
+6
View File
@@ -66,6 +66,12 @@ const ALLOWED_TYPES = new Set([
// Qwen3 auf der Gamebox (via Bridge → RVS → llm-adapter → llama.cpp).
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
"llm_request", "llm_response", "llm_partial",
// Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App)
// spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS
// getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig).
"code_file", "code_file_edit",
"check_desktop", "desktop_status",
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
]);
// Token-Raum: token -> { clients: Set<ws> }