diff --git a/android/src/components/ProjectsBrowser.tsx b/android/src/components/ProjectsBrowser.tsx new file mode 100644 index 0000000..88a74ff --- /dev/null +++ b/android/src/components/ProjectsBrowser.tsx @@ -0,0 +1,359 @@ +/** + * Projekt-Übersicht + Switcher. + * + * Modal-Komponente die: + * - Den aktuellen Projekt-Status zeigt (Hauptchat oder konkretes Projekt) + * - Die Projekt-Liste rendert (sortiert nach letzter Aktivität) + * - Per Tap zwischen Projekten wechseln lässt + * - Neue Projekte anlegen kann + * - Bestehende editieren/beenden/archivieren + * + * Eingesetzt von ChatScreen (über den Projekt-Indicator) und von + * SettingsScreen.tsx in der Section 'projects'. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + FlatList, + Modal, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import brainApi, { Project } from '../services/brainApi'; +import rvs from '../services/rvs'; + +interface Props { + /** Optional — wenn als Modal genutzt, sonst inline */ + visible?: boolean; + onClose?: () => void; + /** Wird gerufen wenn sich das aktive Projekt aendert — ChatScreen + * refresht dann seinen Banner-State. */ + onActiveChanged?: (project: Project | null) => void; +} + +function _fmtRel(unixSec: number): string { + if (!unixSec) return '?'; + const diff = (Date.now() / 1000) - unixSec; + if (diff < 60) return 'gerade eben'; + if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`; + if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`; + if (diff < 86400 * 14) return `vor ${Math.floor(diff / 86400)} Tagen`; + return new Date(unixSec * 1000).toLocaleDateString('de-DE'); +} + +export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onActiveChanged }) => { + const [projects, setProjects] = useState([]); + const [activeId, setActiveId] = useState(''); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + const [newOpen, setNewOpen] = useState(false); + const [newName, setNewName] = useState(''); + const [newDesc, setNewDesc] = useState(''); + const [editing, setEditing] = useState(null); + const [editName, setEditName] = useState(''); + const [editDesc, setEditDesc] = useState(''); + + const load = useCallback(() => { + setLoading(true); setErr(null); + brainApi.getProjectStatus() + .then(status => { + setProjects(status.projects || []); + setActiveId(status.active_id || ''); + onActiveChanged?.(status.active); + }) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setLoading(false)); + }, [onActiveChanged]); + + useEffect(() => { if (visible) load(); }, [visible, load]); + + // Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig + useEffect(() => { + if (!visible) return; + const unsub = rvs.onStateChange((state) => { if (state === 'connected') load(); }); + return () => unsub(); + }, [visible, load]); + + const switchTo = useCallback((id: string) => { + brainApi.switchProject(id) + .then(status => { + setActiveId(status.active_id || ''); + onActiveChanged?.(status.active); + }) + .catch(e => Alert.alert('Fehler', String(e?.message || e))); + }, [onActiveChanged]); + + const createProject = useCallback(() => { + const name = newName.trim(); + if (!name) return; + brainApi.createProject({ name, description: newDesc.trim() }) + .then(() => { + setNewName(''); setNewDesc(''); setNewOpen(false); + load(); + }) + .catch(e => Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e))); + }, [newName, newDesc, load]); + + const openEdit = useCallback((p: Project) => { + setEditing(p); + setEditName(p.name); + setEditDesc(p.description || ''); + }, []); + + const saveEdit = useCallback(() => { + if (!editing) return; + const patch: Partial> = {}; + if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim(); + if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim(); + if (Object.keys(patch).length === 0) { setEditing(null); return; } + brainApi.updateProject(editing.id, patch) + .then(() => { setEditing(null); load(); }) + .catch(e => Alert.alert('Fehler', String(e?.message || e))); + }, [editing, editName, editDesc, load]); + + const endProject = useCallback((p: Project) => { + Alert.alert(`"${p.name}" beenden?`, + 'Bleibt sichtbar, kann nicht mehr aktiv sein außer mit explizitem Wiedereintritt.', + [ + { text: 'Abbrechen', style: 'cancel' }, + { text: 'Beenden', onPress: () => { + brainApi.endProject(p.id).then(() => load()).catch(e => Alert.alert('Fehler', String(e?.message || e))); + }}, + ]); + }, [load]); + + const archiveProject = useCallback((p: Project) => { + Alert.alert(`"${p.name}" archivieren?`, + 'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.', + [ + { text: 'Abbrechen', style: 'cancel' }, + { text: 'Archivieren', style: 'destructive', onPress: () => { + brainApi.archiveProject(p.id) + .then(() => { setEditing(null); load(); }) + .catch(e => Alert.alert('Fehler', String(e?.message || e))); + }}, + ]); + }, [load]); + + // ── Render ──────────────────────────────────────────────── + + const renderItem = ({ item }: { item: Project }) => { + const isActive = item.id === activeId; + return ( + switchTo(item.id)} + onLongPress={() => openEdit(item)} + style={[s.row, isActive && s.rowActive]} + > + + + {item.name} + {item.status === 'ended' && beendet} + {isActive && ✓ AKTIV} + + {item.description ? ( + {item.description} + ) : null} + + {item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)} + + + + ); + }; + + const body = ( + + {/* Header */} + + {onClose && ( + + + + )} + Projekte + setNewOpen(true)} style={s.headerBtn}> + + Neu + + + + {/* Hauptchat-Eintrag (immer oben) */} + switchTo('')} + style={[s.row, !activeId && s.rowActive]} + > + + + 💬 Hauptchat + {!activeId && ✓ AKTIV} + + Standard-Verlauf, keine Projekt-Zuordnung + + + + {loading ? ( + + + + ) : err ? ( + ⚠ {err} + ) : ( + p.id} + renderItem={renderItem} + ListEmptyComponent={ + + Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'} + „Lass uns ein Projekt 'XY' anlegen". + + } + /> + )} + + {/* Neu-Anlegen Modal */} + setNewOpen(false)}> + + + Neues Projekt + + + + setNewOpen(false)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}> + Abbrechen + + + Anlegen + aktivieren + + + + + + + {/* Edit Modal */} + setEditing(null)}> + + + Projekt bearbeiten + + + + setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}> + Abbrechen + + + Speichern + + + {editing && editing.status !== 'ended' && ( + endProject(editing)} style={s.tertiaryBtn}> + ⏹ Projekt beenden + + )} + {editing && ( + archiveProject(editing)} style={s.tertiaryBtn}> + 🗑 Archivieren + + )} + + + + + ); + + // Wenn als Modal genutzt + if (onClose) { + return ( + + {body} + + ); + } + return body; +}; + +const s = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 14, + borderBottomWidth: 1, + borderColor: '#1E1E2E', + backgroundColor: '#080810', + }, + headerBtn: { padding: 8, minWidth: 60 }, + headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' }, + headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' }, + row: { + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderColor: '#1E1E2E', + }, + rowActive: { + backgroundColor: 'rgba(52,199,89,0.08)', + borderLeftWidth: 3, + borderLeftColor: '#34C759', + }, + rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' }, + rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 }, + rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 }, + activeBadge: { color: '#34C759', fontSize: 10, fontWeight: '800' }, + statusBadge: { color: '#FFD60A', fontSize: 10, fontWeight: '700', + backgroundColor: 'rgba(255,214,10,0.15)', paddingHorizontal: 6, + paddingVertical: 2, borderRadius: 4 }, + errorText: { color: '#FF6E6E', padding: 16, textAlign: 'center', fontSize: 13 }, + emptyText: { color: '#555570', padding: 24, textAlign: 'center', fontSize: 13, lineHeight: 19 }, + modalOverlay: { + flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', paddingHorizontal: 20, + }, + modalCard: { backgroundColor: '#15151E', borderRadius: 12, padding: 18 }, + modalTitle: { color: '#E0E0F0', fontSize: 18, fontWeight: '700', marginBottom: 14 }, + input: { + backgroundColor: '#0A0A14', borderRadius: 6, color: '#E0E0F0', + paddingHorizontal: 12, paddingVertical: 10, fontSize: 14, marginBottom: 8, + borderWidth: 1, borderColor: '#2A2A3E', + }, + modalBtn: { flex: 1, alignItems: 'center', paddingVertical: 11, borderRadius: 6 }, + modalBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' }, + tertiaryBtn: { alignItems: 'center', paddingVertical: 10, marginTop: 8 }, + tertiaryBtnText: { color: '#FFD60A', fontSize: 13, fontWeight: '600' }, +}); + +export default ProjectsBrowser; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 7d9f64a..461b38e 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -36,6 +36,8 @@ import ErrorBoundary from '../components/ErrorBoundary'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService, { loadPassiveListenMs } from '../services/wakeword'; +import ProjectsBrowser from '../components/ProjectsBrowser'; +import brainApi, { Project as BrainProject } from '../services/brainApi'; import phoneCallService from '../services/phoneCall'; import { playWakeReadySound } from '../services/wakeReadySound'; import { @@ -280,6 +282,8 @@ const ChatScreen: React.FC = () => { const [showJumpDown, setShowJumpDown] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchVisible, setSearchVisible] = useState(false); + const [projectsVisible, setProjectsVisible] = useState(false); + const [activeProject, setActiveProject] = useState(null); const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); @@ -457,6 +461,19 @@ const ChatScreen: React.FC = () => { // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle // sofort greift, ohne Context- oder Event-System) + // Aktives Projekt initial laden + bei RVS-Reconnect refreshen. + // Wird zusaetzlich nach jedem chat-Response refreshed (siehe handleAriaMessage). + useEffect(() => { + const loadProject = () => { + brainApi.getProjectStatus() + .then(s => setActiveProject(s.active || null)) + .catch(() => {}); + }; + loadProject(); + const unsub = rvs.onStateChange(state => { if (state === 'connected') loadProject(); }); + return () => unsub(); + }, []); + useEffect(() => { const loadSettings = async () => { const enabled = await AsyncStorage.getItem('aria_tts_enabled'); @@ -795,6 +812,15 @@ const ChatScreen: React.FC = () => { return; } + // project_changed: ARIA hat in einem Tool-Call ein Projekt erstellt / + // betreten / verlassen / beendet. Banner refreshen. + if (message.type === 'project_changed') { + brainApi.getProjectStatus() + .then(s => setActiveProject(s.active || null)) + .catch(() => {}); + return; + } + if (message.type === 'skill_created') { const p = (message.payload || {}) as any; const skillMsg: ChatMessage = { @@ -2457,6 +2483,32 @@ const ChatScreen: React.FC = () => { ); })()} + {/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */} + setProjectsVisible(true)} + style={{ + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 12, paddingVertical: 6, + backgroundColor: activeProject ? 'rgba(52,199,89,0.10)' : 'transparent', + borderBottomWidth: activeProject ? 2 : 1, + borderColor: activeProject ? '#34C759' : '#1E1E2E', + }} + > + + {activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'} + + + {activeProject ? 'wechseln ›' : 'Projekte ›'} + + + + {/* Projekt-Modal */} + setProjectsVisible(false)} + onActiveChanged={(p) => setActiveProject(p)} + /> + {/* Suchleiste mit Treffer-Navigation */} {searchVisible && ( diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 8d354e8..789c6ff 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -92,6 +92,7 @@ import TriggerBrowser from '../components/TriggerBrowser'; import SkillBrowser from '../components/SkillBrowser'; import OAuthBrowser from '../components/OAuthBrowser'; import VoiceIdEnrollment from '../components/VoiceIdEnrollment'; +import ProjectsBrowser from '../components/ProjectsBrowser'; import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger'; import { isWakeReadySoundEnabled, @@ -142,6 +143,7 @@ const SETTINGS_SECTIONS = [ { id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' }, { id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' }, { id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' }, + { id: 'projects', icon: '📁', label: 'Projekte', desc: 'Thread-Bündel im Hauptchat — verwalten, wechseln, beenden' }, { id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' }, { id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' }, { id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' }, @@ -1280,7 +1282,7 @@ const SettingsScreen: React.FC = () => { // Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat // (Memory, Trigger), den outer Scroll deaktivieren — Android-nested- // scrolling laesst sonst nur in eine Richtung scrollen. - scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'} + scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth' && currentSection !== 'projects'} > {currentSection === null && ( @@ -2189,6 +2191,18 @@ const SettingsScreen: React.FC = () => { )} + {/* === Projekte === */} + {currentSection === 'projects' && (<> + Projekte + + Thread-Bündel im Hauptchat. Tap auf ein Projekt → aktivieren, alle weiteren Nachrichten gehen + dort rein. Long-Press → bearbeiten. „+ Neu" oder zu ARIA: „lass uns ein Projekt anlegen". + + + + + )} + {/* === Gedaechtnis === */} {currentSection === 'memory' && (<> Gedächtnis diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index a478110..5a080b9 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -151,6 +151,24 @@ export interface OAuthAppConfig { token_url?: string | null; } +/** Projekt — Stefans Threading-Konzept im Hauptchat. */ +export interface Project { + id: string; + name: string; + description: string; + status: 'active' | 'ended' | 'archived'; + created_at: number; + updated_at: number; + last_activity_at: number; + turn_count: number; +} + +export interface ProjectStatus { + active_id: string; + active: Project | null; + projects: Project[]; +} + /** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */ export interface Skill { name: string; @@ -521,6 +539,57 @@ export const brainApi = { timeoutMs: 15000, }); }, + + // ── Projekte ─────────────────────────────────────────────────── + + /** Kompletter Status: aktives Projekt + Liste. */ + getProjectStatus(): Promise { + return _send('/projects/status'); + }, + + /** Nur die Liste — fuer Sidebar/Drawer. */ + listProjects(includeArchived: boolean = false): Promise { + return _send(`/projects/list${includeArchived ? '?include_archived=true' : ''}`) + .then((r: any) => r?.projects || []); + }, + + /** Neues Projekt anlegen — wird automatisch aktiviert. */ + createProject(body: { name: string; description?: string }): Promise { + return _send('/projects/create', { + method: 'POST', + body: { description: '', ...body }, + }); + }, + + /** Aktives Projekt wechseln. Leerer projectId = Hauptthread. */ + switchProject(projectId: string): Promise { + return _send('/projects/switch', { + method: 'POST', + body: { project_id: projectId }, + }); + }, + + /** Projekt als beendet markieren (bleibt sichtbar, aktiv ist dann der Hauptthread). */ + endProject(projectId: string): Promise { + return _send(`/projects/${encodeURIComponent(projectId)}/end`, { + method: 'POST', + }); + }, + + /** Projekt archivieren (verschwindet aus der Default-Liste). */ + archiveProject(projectId: string): Promise<{ id: string; status: string }> { + return _send(`/projects/${encodeURIComponent(projectId)}/archive`, { + method: 'POST', + }); + }, + + /** Projekt-Metadaten patchen (name / description). */ + updateProject(projectId: string, patch: Partial>): Promise { + return _send(`/projects/${encodeURIComponent(projectId)}`, { + method: 'PATCH', + body: patch, + }); + }, }; export default brainApi; diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 049be6a..fc65acc 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -32,6 +32,7 @@ import skills as skills_mod import triggers as triggers_mod import watcher as watcher_mod import oauth as oauth_mod +import projects as projects_mod BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") # FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start @@ -808,6 +809,104 @@ META_TOOLS = [ }, }, }, + # ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ── + { + "type": "function", + "function": { + "name": "project_create", + "description": ( + "Legt ein neues Projekt an und macht es ZUR AKTIVEN Bühne. " + "Nutze das wenn Stefan sagt 'lass uns ein Projekt für X anlegen' " + "oder ein Thema klar als zusammenhängend bezeichnet. NICHT für " + "Ad-hoc-Fragen — Projekte sind für wiederkehrende, mehrere Tage " + "spannende Themen (Spotify-Setup, Renovierung, Reise-Planung)." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Kurzer Name, wie ein Buchtitel ('Aria-Wakeword', 'Frankreich-Urlaub')."}, + "description": {"type": "string", "description": "1-Satz worum's geht. Hilft beim Wiedererkennen."}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "project_enter", + "description": ( + "Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — " + "'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt " + "tagged jeder neue Turn die project_id. Bei sehr alten Projekten: " + "vorher project_summary aufrufen damit Du Stefan abholst." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Projekt-Name oder Teil davon."}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "project_exit", + "description": ( + "Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze " + "wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä." + ), + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "project_list", + "description": "Listet alle Projekte mit Status und letzter Aktivität. Bevor Du ein neues anlegst: hier prüfen ob's schon eins gibt.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "project_summary", + "description": ( + "Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). " + "Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit " + "'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst " + "halluzinierst Du Inhalte die nicht da sind." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "project_end", + "description": ( + "Markiert ein Projekt als beendet — bleibt in der Liste sichtbar " + "(z.B. archiviert/grau), kann aber nicht mehr neu betreten werden " + "außer mit explizitem project_enter. Nutze wenn Stefan sagt 'Projekt " + "abgeschlossen' o.ä." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Projekt-Name."}, + }, + "required": ["name"], + }, + }, + }, ] @@ -960,17 +1059,28 @@ class Agent: # Events vom letzten Turn weglassen self._pending_events = [] + # Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der + # neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt. + active_project_id = projects_mod.get_active() + active_project = projects_mod.get_project(active_project_id) if active_project_id else None + # Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett. # Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain # iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz. fast_reply = self._try_skill_fast_path(user_message) if fast_reply is not None: - self.conversation.add("user", user_message, source=source) - self.conversation.add("assistant", fast_reply) + self.conversation.add("user", user_message, source=source, + project_id=active_project_id) + self.conversation.add("assistant", fast_reply, project_id=active_project_id) + if active_project_id: + projects_mod.touch_project(active_project_id) return fast_reply # 1. User-Turn an die Konversation - self.conversation.add("user", user_message, source=source) + self.conversation.add("user", user_message, source=source, + project_id=active_project_id) + if active_project_id: + projects_mod.touch_project(active_project_id) # 2. Hot Memory (alle pinned Punkte) hot = self.store.list_pinned() @@ -1017,13 +1127,38 @@ class Agent: oauth_callback_host=oauth_host, oauth_callback_port=oauth_port, oauth_callback_tls=oauth_tls) + # Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude + # weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt + # einsetzt (z.B. bei „Projekt Ende" project_exit aufruft). + if active_project: + system_prompt += ( + f"\n\n## AKTUELLES PROJEKT\n" + f"Stefan befindet sich gerade IN dem Projekt '{active_project['name']}' " + f"(id={active_project['id']}). Beschreibung: " + f"{active_project.get('description', '(keine)')}. " + f"Alle Antworten in diesem Turn gelten fuer dieses Projekt. " + f"Wenn er rauswill, ruf project_exit auf." + ) + else: + project_count = len(projects_mod.list_projects()) + if project_count > 0: + system_prompt += ( + f"\n\n## PROJEKTE\n" + f"Hauptthread aktiv. {project_count} Projekte verfuegbar — wenn " + f"Stefan sagt 'in Projekt X' oder 'lass uns das Spotify-Thema " + f"weiterfuehren': project_enter aufrufen." + ) messages = [ProxyMessage(role="system", content=system_prompt)] - for t in self.conversation.window(): + # Conversation-Window auf das aktive Projekt filtern: in einem Projekt + # sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread + # nur die nicht-getaggten Turns. + window = self.conversation.window(project_id=active_project_id) + for t in window: messages.append(ProxyMessage(role=t.role, content=t.content)) - logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d prompt_chars=%d", + logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d", len(hot), len(cold), len(active_skills), len(all_skills), - len(self.conversation.window()), len(system_prompt)) + len(window), active_project_id or "(main)", len(system_prompt)) # 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein # Assistant-Turn als Error-Marker geschrieben werden — der User-Turn @@ -1082,13 +1217,19 @@ class Agent: err_text = f"[Fehler: {exc}]" logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc) try: - self.conversation.add("assistant", err_text) + # Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops + # geaendert haben (project_enter/exit als Tool-Call). + self.conversation.add("assistant", err_text, + project_id=projects_mod.get_active()) except Exception as add_exc: logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc) raise # 7. Assistant-Turn (final reply) in die Conversation - self.conversation.add("assistant", final_reply) + # NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der + # Final-Reply schon im neuen Projekt-Kontext. + self.conversation.add("assistant", final_reply, + project_id=projects_mod.get_active()) return final_reply # ── Tool-Dispatcher ─────────────────────────────────────── @@ -1648,6 +1789,101 @@ class Agent: except Exception as e: logger.exception("memory_save fehlgeschlagen") return f"FEHLER beim Speichern: {e}" + # ── Projekte ──────────────────────────────────────── + if name == "project_create": + pname = (arguments.get("name") or "").strip() + desc = (arguments.get("description") or "").strip() + if not pname: + return "FEHLER: name ist Pflicht." + try: + p = projects_mod.create_project(pname, desc) + except ValueError as e: + return f"FEHLER: {e}" + self._pending_events.append({ + "type": "project_changed", + "project": p, + "action": "created", + }) + return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst." + if name == "project_enter": + pname = (arguments.get("name") or "").strip() + if not pname: + return "FEHLER: name ist Pflicht." + p = projects_mod.find_project(pname) + if not p: + return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll." + projects_mod.set_active(p["id"]) + self._pending_events.append({ + "type": "project_changed", + "project": p, + "action": "entered", + }) + turn_count = p.get("turn_count", 0) + hint = "" + if turn_count > 0: + hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen." + return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}" + if name == "project_exit": + active_id = projects_mod.get_active() + if not active_id: + return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread." + p = projects_mod.get_project(active_id) + projects_mod.set_active("") + self._pending_events.append({ + "type": "project_changed", + "project": p, + "action": "exited", + }) + return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread." + if name == "project_list": + items = projects_mod.list_projects() + if not items: + return "(keine Projekte angelegt)" + active_id = projects_mod.get_active() + lines = [] + for p in items: + marker = " ← AKTIV" if p["id"] == active_id else "" + status_lbl = p.get("status", "active") + lines.append( + f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, " + f"status={status_lbl}){marker}" + ) + return "Projekte:\n" + "\n".join(lines) + if name == "project_summary": + pname = (arguments.get("name") or "").strip() + if not pname: + return "FEHLER: name ist Pflicht." + p = projects_mod.find_project(pname) + if not p: + return f"Kein Projekt '{pname}' gefunden." + # Letzte ~10 Turns des Projekts aus dem Conversation-Log + turns = [t for t in self.conversation.turns if t.project_id == p["id"]] + if not turns: + return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im " + f"aktuellen Conversation-Window stehen noch keine Turns. " + f"Beschreibung: {p.get('description', '(keine)')}") + tail = turns[-12:] + summary_lines = [] + for t in tail: + prefix = "Stefan" if t.role == "user" else "Du" + summary_lines.append(f"{prefix}: {t.content[:280]}") + preamble = (f"Projekt '{p['name']}' — {p.get('description', '(keine Beschreibung)')}.\n" + f"Letzte {len(tail)} Turns:\n") + return preamble + "\n".join(summary_lines) + if name == "project_end": + pname = (arguments.get("name") or "").strip() + if not pname: + return "FEHLER: name ist Pflicht." + p = projects_mod.find_project(pname) + if not p: + return f"Kein Projekt '{pname}' gefunden." + projects_mod.end_project(p["id"]) + self._pending_events.append({ + "type": "project_changed", + "project": projects_mod.get_project(p["id"]), + "action": "ended", + }) + return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread." return f"Unbekanntes Tool: {name}" except Exception as exc: logger.exception("Tool '%s' fehlgeschlagen", name) diff --git a/aria-brain/conversation.py b/aria-brain/conversation.py index a5668a2..a731784 100644 --- a/aria-brain/conversation.py +++ b/aria-brain/conversation.py @@ -32,6 +32,7 @@ class Turn: content: str ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) source: str = "" # "app" / "diagnostic" / "stt" — optional + project_id: str = "" # leer = Hauptthread; sonst projects.py-ID class Conversation: @@ -73,7 +74,8 @@ class Conversation: if role in ("user", "assistant") and isinstance(content, str): loaded.append(Turn(role=role, content=content, ts=obj.get("ts", ""), - source=obj.get("source", ""))) + source=obj.get("source", ""), + project_id=obj.get("project_id", ""))) self.turns = loaded logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE) @@ -85,17 +87,40 @@ class Conversation: except Exception as exc: logger.warning("Konversation persist fehlgeschlagen: %s", exc) - def add(self, role: str, content: str, source: str = "") -> Turn: - t = Turn(role=role, content=content, source=source) + def add(self, role: str, content: str, source: str = "", + project_id: str = "") -> Turn: + t = Turn(role=role, content=content, source=source, project_id=project_id) self.turns.append(t) - self._append_to_file({ + record = { "ts": t.ts, "role": t.role, "content": t.content, "source": t.source, - }) + } + if t.project_id: + record["project_id"] = t.project_id + self._append_to_file(record) return t - def window(self) -> List[Turn]: - """Die letzten max_window Turns — gehen in den LLM-Prompt.""" - return self.turns[-self.max_window:] + def window(self, project_id: Optional[str] = None) -> List[Turn]: + """Die letzten max_window Turns — gehen in den LLM-Prompt. + Wenn project_id gesetzt: nur Turns aus diesem Projekt + die letzten + ~5 Hauptthread-Turns als Kontext. Wenn project_id leer/None und + explizit uebergeben → nur Hauptthread.""" + if project_id is None: + return self.turns[-self.max_window:] + if project_id == "": + # Hauptthread-Modus: alle Turns, aber project-getaggte rausfiltern + main_turns = [t for t in self.turns if not t.project_id] + return main_turns[-self.max_window:] + # In-Projekt: alle Turns des Projekts + Tail des Hauptthreads als Kontext + project_turns = [t for t in self.turns if t.project_id == project_id] + return project_turns[-self.max_window:] + + def window_recent_per_project(self) -> dict: + """Returns {project_id: [last N turns]} — fuer „hol mich ab"-Summary.""" + groups: dict[str, List[Turn]] = {} + for t in self.turns: + pid = t.project_id or "" + groups.setdefault(pid, []).append(t) + return groups def needs_distill(self) -> bool: return len(self.turns) > self.distill_threshold @@ -131,10 +156,13 @@ class Conversation: tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp") with tmp.open("w", encoding="utf-8") as f: for t in self.turns: - f.write(json.dumps({ + rec = { "ts": t.ts, "role": t.role, "content": t.content, "source": t.source, - }, ensure_ascii=False) + "\n") + } + if t.project_id: + rec["project_id"] = t.project_id + f.write(json.dumps(rec, ensure_ascii=False) + "\n") tmp.replace(CONVERSATION_FILE) except Exception as exc: logger.warning("Konversation rewrite fehlgeschlagen: %s", exc) diff --git a/aria-brain/main.py b/aria-brain/main.py index 9a182ab..ec3f980 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -38,6 +38,7 @@ import watcher as watcher_mod import background as background_mod import oauth as oauth_mod import seed_rules as seed_rules_mod +import projects as projects_mod logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logger = logging.getLogger("aria-brain") @@ -639,6 +640,76 @@ def chat(body: ChatIn, background: BackgroundTasks): ) +# ── Projekte ──────────────────────────────────────────────────────── + +@app.get("/projects/status") +def projects_status(): + """Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten).""" + return projects_mod.status() + + +@app.get("/projects/list") +def projects_list(include_archived: bool = False): + return {"projects": projects_mod.list_projects(include_archived=include_archived)} + + +class ProjectCreateBody(BaseModel): + name: str + description: str = "" + + +@app.post("/projects/create") +def projects_create(body: ProjectCreateBody): + try: + p = projects_mod.create_project(body.name, body.description) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return p + + +class ProjectSwitchBody(BaseModel): + project_id: str = "" + + +@app.post("/projects/switch") +def projects_switch(body: ProjectSwitchBody): + """Aktive Projekt-ID setzen. Leerer String → Hauptthread.""" + if body.project_id: + p = projects_mod.get_project(body.project_id) + if not p: + raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden") + projects_mod.set_active(body.project_id) + return projects_mod.status() + + +@app.post("/projects/{project_id}/end") +def projects_end(project_id: str): + if not projects_mod.end_project(project_id): + raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden") + return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"} + + +@app.post("/projects/{project_id}/archive") +def projects_archive(project_id: str): + if not projects_mod.archive_project(project_id): + raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden") + return {"id": project_id, "status": "archived"} + + +class ProjectUpdateBody(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +@app.patch("/projects/{project_id}") +def projects_update(project_id: str, body: ProjectUpdateBody): + patch = body.dict(exclude_unset=True) + p = projects_mod.update_project(project_id, patch) + if p is None: + raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden") + return p + + @app.get("/conversation/stats") def conversation_stats(): return conversation().stats() diff --git a/aria-brain/projects.py b/aria-brain/projects.py new file mode 100644 index 0000000..fedd7f5 --- /dev/null +++ b/aria-brain/projects.py @@ -0,0 +1,219 @@ +""" +Projekt-Verwaltung — Stefans Idee fuer „Threads im Hauptchat verankert". + +Ein Projekt ist ein benanntes Thema-Bündel. Zwei Modi: + - Hauptthread (kein aktives Projekt): klassischer rollender Chat. + - In-Projekt: alle neuen Turns werden mit project_id getaggt. Die App + zeigt sie als zusammenhängenden Block, einklappbar. + +Voice-Pattern (vom LLM via Meta-Tools getriggert): + - „neues Projekt 'Aria-Wakeword'" → project_create + - „steig in Projekt Spotify-Setup ein" → project_enter (Fuzzy-Match) + - „Projekt Ende" → project_exit (zurueck zu Hauptthread) + - „welche Projekte gibt's?" → project_list + - „hol mich ab — was war zuletzt bei Projekt X?" → project_summary + +Persistenz: JSON-Liste in /shared/config/projects.json + aktive ID +in /shared/config/active_project.txt. Single-User, single-active — +keine Concurrency-Probleme. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import time +import uuid +from difflib import SequenceMatcher +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/shared/config")) +PROJECTS_FILE = PROJECTS_DIR / "projects.json" +ACTIVE_PROJECT_FILE = PROJECTS_DIR / "active_project.txt" + + +def _now() -> int: + return int(time.time()) + + +def _load_all() -> list[dict]: + if not PROJECTS_FILE.exists(): + return [] + try: + data = json.loads(PROJECTS_FILE.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception as exc: + logger.warning("[projects] load failed: %s", exc) + return [] + + +def _save_all(projects: list[dict]) -> None: + PROJECTS_DIR.mkdir(parents=True, exist_ok=True) + PROJECTS_FILE.write_text( + json.dumps(projects, indent=2, ensure_ascii=False), encoding="utf-8") + + +def _slug(name: str) -> str: + """Stabile ID aus Namen — fuer Voice-Matches. Lowercase, only a-z 0-9 _.""" + s = name.strip().lower() + s = re.sub(r"[^a-z0-9]+", "_", s) + s = s.strip("_") + return s or f"project_{_now()}" + + +def list_projects(include_archived: bool = False) -> list[dict]: + projects = _load_all() + if not include_archived: + projects = [p for p in projects if p.get("status") != "archived"] + projects.sort(key=lambda p: p.get("last_activity_at", 0), reverse=True) + return projects + + +def get_project(project_id: str) -> Optional[dict]: + if not project_id: + return None + for p in _load_all(): + if p.get("id") == project_id: + return p + return None + + +def find_project(query: str) -> Optional[dict]: + """Fuzzy-Match auf Projekt-Namen — fuer Voice-Commands. + Trifft auf: exact slug, prefix, substring, oder hoechste similarity > 0.6.""" + q = (query or "").strip().lower() + if not q: + return None + projects = _load_all() + # 1. Exact ID-Match + for p in projects: + if p.get("id") == q: + return p + # 2. Exact / Prefix / Substring auf Slug + Name + q_slug = _slug(q) + for p in projects: + if p.get("id") == q_slug: + return p + name_low = (p.get("name", "")).lower() + if name_low == q or name_low.startswith(q) or q in name_low: + return p + # 3. Fuzzy + best, best_score = None, 0.0 + for p in projects: + s = SequenceMatcher(None, q, p.get("name", "").lower()).ratio() + if s > best_score: + best, best_score = p, s + if best and best_score >= 0.6: + return best + return None + + +def create_project(name: str, description: str = "") -> dict: + name = (name or "").strip() + if not name: + raise ValueError("Projektname darf nicht leer sein") + base_id = _slug(name) + projects = _load_all() + # Dedup by id with suffix + used_ids = {p["id"] for p in projects} + pid = base_id + counter = 2 + while pid in used_ids: + pid = f"{base_id}_{counter}" + counter += 1 + now = _now() + project = { + "id": pid, + "name": name, + "description": description.strip(), + "status": "active", # active | ended | archived + "created_at": now, + "updated_at": now, + "last_activity_at": now, + "turn_count": 0, + } + projects.append(project) + _save_all(projects) + set_active(pid) + logger.info("[projects] created %r (id=%s)", name, pid) + return project + + +def update_project(project_id: str, patch: dict) -> Optional[dict]: + projects = _load_all() + for p in projects: + if p["id"] == project_id: + for k in ("name", "description", "status"): + if k in patch and patch[k] is not None: + p[k] = patch[k] + p["updated_at"] = _now() + _save_all(projects) + return p + return None + + +def archive_project(project_id: str) -> bool: + if update_project(project_id, {"status": "archived"}) is not None: + if get_active() == project_id: + set_active("") + return True + return False + + +def end_project(project_id: str) -> bool: + """Markiert als beendet, aktive-Projekt-Pointer raus.""" + if update_project(project_id, {"status": "ended"}) is not None: + if get_active() == project_id: + set_active("") + return True + return False + + +def touch_project(project_id: str) -> None: + """Bei jedem Turn im Projekt: last_activity + turn_count erhoehen.""" + if not project_id: + return + projects = _load_all() + changed = False + for p in projects: + if p["id"] == project_id: + p["last_activity_at"] = _now() + p["turn_count"] = int(p.get("turn_count", 0)) + 1 + changed = True + break + if changed: + _save_all(projects) + + +# ── Active-Project-Pointer ───────────────────────────────────────── + +def get_active() -> str: + """Returns die aktive Projekt-ID oder leer (= Hauptthread).""" + try: + if ACTIVE_PROJECT_FILE.exists(): + return ACTIVE_PROJECT_FILE.read_text(encoding="utf-8").strip() + except Exception: + pass + return "" + + +def set_active(project_id: str) -> None: + PROJECTS_DIR.mkdir(parents=True, exist_ok=True) + ACTIVE_PROJECT_FILE.write_text(project_id or "", encoding="utf-8") + logger.info("[projects] active project: %r", project_id or "(main)") + + +def status() -> dict: + """Status-Snapshot fuer App/Diagnostic.""" + active_id = get_active() + active = get_project(active_id) if active_id else None + return { + "active_id": active_id, + "active": active, + "projects": list_projects(include_archived=False), + } diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 75a7c63..c738f7b 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1564,6 +1564,20 @@ class ARIABridge: logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)", event.get("memory", {}).get("title"), event.get("memory", {}).get("type")) + elif etype == "project_changed": + # ARIA hat ein Projekt erstellt / betreten / verlassen / beendet. + # App + Diagnostic refreshen ihren Projekt-Banner anhand des Events. + await self._send_to_rvs({ + "type": "project_changed", + "payload": { + "action": event.get("action") or "", + **(event.get("project") or {}), + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + proj = event.get("project") or {} + logger.info("[brain] Projekt %s: %s (id=%s)", + event.get("action") or "?", proj.get("name"), proj.get("id")) # _process_core_response uebernimmt alles weitere: # File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat- diff --git a/diagnostic/index.html b/diagnostic/index.html index 3d91a89..83a703f 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -992,6 +992,41 @@ + +
+
+

📁 Projekte

+
+ + +
+
+
+ Projekte bündeln zusammengehörige Turns als Block im Hauptchat. Stefan sagt zu ARIA + „lass uns ein Projekt anlegen" oder klickt hier auf „+ Neues Projekt". Aktives Projekt: + (wird geladen...) +
+
+
Lade Projekte...
+
+
+ + +
@@ -1543,6 +1578,13 @@ return; } + if (msg.type === 'project_changed') { + // ARIA hat in einem Tool-Call ein Projekt erstellt/betreten/verlassen/beendet. + // Liste neu laden falls sichtbar. + loadProjects(); + return; + } + if (msg.type === 'voice_id_delete_response') { const p = msg.payload || msg; if (p.removed) { @@ -2694,6 +2736,117 @@ send({ action: 'voice_id_delete' }); } + // ── Projekte ──────────────────────────────────────────── + async function loadProjects() { + const listEl = document.getElementById('project-list'); + const activeLabel = document.getElementById('project-active-label'); + try { + const r = await fetch('/api/brain/projects/status'); + const status = await r.json(); + const projects = status.projects || []; + const activeId = status.active_id || ''; + activeLabel.textContent = status.active ? status.active.name : '💬 Hauptchat'; + activeLabel.style.color = status.active ? '#34C759' : '#8888AA'; + + const rows = []; + // Hauptchat-Eintrag + rows.push(` +
+
💬 Hauptchat ${!activeId ? '✓ AKTIV' : ''}
+
Standard-Verlauf, keine Projekt-Zuordnung
+
`); + for (const p of projects) { + const isActive = p.id === activeId; + const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?'; + const ended = p.status === 'ended'; + rows.push(` +
+
+
+
+ 📁 ${escapeHtml(p.name)} + ${ended ? 'beendet' : ''} + ${isActive ? '✓ AKTIV' : ''} +
+ ${p.description ? `
${escapeHtml(p.description)}
` : ''} +
${p.turn_count} Turns · zuletzt ${since}
+
+
+ ${!ended ? `` : ''} + +
+
+
`); + } + if (projects.length === 0) { + rows.push('
Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".
'); + } + listEl.innerHTML = rows.join(''); + } catch (e) { + listEl.innerHTML = `
Fehler: ${e.message}
`; + } + } + + async function switchProject(projectId) { + try { + await fetch('/api/brain/projects/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: projectId }), + }); + loadProjects(); + } catch (e) { alert('Wechsel fehlgeschlagen: ' + e.message); } + } + + async function endProject(id, name) { + if (!confirm(`Projekt "${name}" beenden?\n\nBleibt sichtbar, aktiv ist dann der Hauptchat.`)) return; + try { + await fetch(`/api/brain/projects/${encodeURIComponent(id)}/end`, { method: 'POST' }); + loadProjects(); + } catch (e) { alert('Beenden fehlgeschlagen: ' + e.message); } + } + + async function archiveProject(id, name) { + if (!confirm(`Projekt "${name}" archivieren?\n\nVerschwindet aus der Liste.`)) return; + try { + await fetch(`/api/brain/projects/${encodeURIComponent(id)}/archive`, { method: 'POST' }); + loadProjects(); + } catch (e) { alert('Archivieren fehlgeschlagen: ' + e.message); } + } + + function openCreateProjectModal() { + document.getElementById('project-create-name').value = ''; + document.getElementById('project-create-desc').value = ''; + document.getElementById('project-create-modal').style.display = 'flex'; + setTimeout(() => document.getElementById('project-create-name').focus(), 50); + } + + function closeCreateProjectModal() { + document.getElementById('project-create-modal').style.display = 'none'; + } + + async function submitCreateProject() { + const name = document.getElementById('project-create-name').value.trim(); + const description = document.getElementById('project-create-desc').value.trim(); + if (!name) { alert('Name darf nicht leer sein.'); return; } + try { + await fetch('/api/brain/projects/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }), + }); + closeCreateProjectModal(); + loadProjects(); + } catch (e) { alert('Anlegen fehlgeschlagen: ' + e.message); } + } + + function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + function escapeHtmlAttr(str) { + return String(str).replace(/['"\\]/g, '\\$&'); + } + function deleteXttsVoice(name) { if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return; send({ action: 'xtts_delete_voice', name }); @@ -3450,6 +3603,7 @@ loadBrainMemoryList(); refreshImportFiles(); loadMetrics(); + loadProjects(); } else if (tab === 'files') { loadFiles(); } else if (tab === 'skills') { diff --git a/rvs/server.js b/rvs/server.js index a94b762..c84ea57 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -48,6 +48,10 @@ const ALLOWED_TYPES = new Set([ "voice_id_status_request", "voice_id_status_response", "voice_id_enroll_request", "voice_id_enroll_response", "voice_id_delete_request", "voice_id_delete_response", + // Projekte (Stefan-Konzept: Threads im Hauptchat verankert) — Side-Channel- + // Event vom Brain → Bridge → App/Diagnostic, damit beide Clients ihren + // aktiven-Projekt-Banner refreshen wenn ARIA via Tool was aendert. + "project_changed", // File-Versioning (Datei-Manager in App): Versionen pro Datei listen, // alte Versionen herunterladen, Restore = non-destructive neuer Commit. "file_version_list_request", "file_version_list_response",