Compare commits
14 Commits
a0dc0cf20e
...
v0.1.9.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 882f3def99 | |||
| 092f085254 | |||
| 21eac63723 | |||
| 06316da36f | |||
| 7927ad05ae | |||
| 5b2c552a88 | |||
| f51ad1547d | |||
| 2a2700907c | |||
| 93ecbf6c43 | |||
| d430fa113e | |||
| 1fb512c2fd | |||
| 1baa1a7a08 | |||
| fc0f91d1e6 | |||
| f714cfc336 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10902
|
||||
versionName "0.1.9.2"
|
||||
versionCode 10908
|
||||
versionName "0.1.9.8"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.9.2",
|
||||
"version": "0.1.9.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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, useRef, 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 Stefan ein anderes Projekt fokussiert (App-lokale
|
||||
* UI-Entscheidung, wechselt den Chat-Focus). */
|
||||
onActiveChanged?: (project: Project | null) => void;
|
||||
/** Queue-Status pro Kontext (key "__main__" = Hauptchat, sonst project_id).
|
||||
* Wenn geliefert: Status-Dot pro Zeile gerendert. */
|
||||
queueStatus?: Record<string, { busy: boolean; queue_size: number }>;
|
||||
}
|
||||
|
||||
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<Props> = ({ visible = true, onClose, onActiveChanged, queueStatus }) => {
|
||||
const _statusDot = (pid: string) => {
|
||||
const s = queueStatus?.[pid];
|
||||
if (!s) return { color: '#555570', label: '' };
|
||||
if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' };
|
||||
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
|
||||
return { color: '#34C759', label: 'idle' };
|
||||
};
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [editing, setEditing] = useState<Project | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
|
||||
// Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt
|
||||
// wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes
|
||||
// Render → useCallback re-runs → useEffect refeuert → infinite spinner).
|
||||
const onActiveChangedRef = useRef(onActiveChanged);
|
||||
useEffect(() => { onActiveChangedRef.current = onActiveChanged; }, [onActiveChanged]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
brainApi.getProjectStatus()
|
||||
.then(status => {
|
||||
setProjects(status.projects || []);
|
||||
setActiveId(status.active_id || '');
|
||||
onActiveChangedRef.current?.(status.active);
|
||||
})
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
|
||||
// Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr).
|
||||
// Wir suchen das Projekt lokal aus der Liste, damit die App den Namen kennt.
|
||||
setActiveId(id);
|
||||
const p = id ? (projects.find(x => x.id === id) || null) : null;
|
||||
onActiveChangedRef.current?.(p);
|
||||
if (onClose) onClose();
|
||||
}, [projects, onClose]);
|
||||
|
||||
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<Pick<Project, 'name' | 'description'>> = {};
|
||||
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;
|
||||
const dot = _statusDot(item.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => switchTo(item.id)}
|
||||
onLongPress={() => openEdit(item)}
|
||||
style={[s.row, isActive && s.rowActive]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
{queueStatus && (
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||
)}
|
||||
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
||||
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
||||
{isActive && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||||
</View>
|
||||
{item.description ? (
|
||||
<Text style={s.rowDesc} numberOfLines={2}>{item.description}</Text>
|
||||
) : null}
|
||||
<Text style={s.rowMeta}>
|
||||
{item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)}
|
||||
{dot.label ? ` · ${dot.label}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const body = (
|
||||
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
{onClose && (
|
||||
<TouchableOpacity onPress={onClose} style={s.headerBtn}>
|
||||
<Text style={s.headerBtnText}>‹</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={s.headerTitle}>Projekte</Text>
|
||||
<TouchableOpacity onPress={() => setNewOpen(true)} style={s.headerBtn}>
|
||||
<Text style={[s.headerBtnText, { color: '#34C759' }]}>+ Neu</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Hauptchat-Eintrag (immer oben) */}
|
||||
{(() => {
|
||||
const dot = _statusDot('__main__');
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => switchTo('')}
|
||||
style={[s.row, !activeId && s.rowActive]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
{queueStatus && (
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||
)}
|
||||
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
|
||||
{!activeId && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||||
</View>
|
||||
<Text style={s.rowMeta}>
|
||||
Standard-Verlauf, keine Projekt-Zuordnung
|
||||
{dot.label ? ` · ${dot.label}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})()}
|
||||
|
||||
{loading ? (
|
||||
<View style={{ padding: 24, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="#0096FF" />
|
||||
</View>
|
||||
) : err ? (
|
||||
<Text style={s.errorText}>⚠ {err}</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={projects}
|
||||
keyExtractor={p => p.id}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
<Text style={s.emptyText}>
|
||||
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
|
||||
„Lass uns ein Projekt 'XY' anlegen".
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Neu-Anlegen Modal */}
|
||||
<Modal visible={newOpen} animationType="slide" transparent onRequestClose={() => setNewOpen(false)}>
|
||||
<View style={s.modalOverlay}>
|
||||
<View style={s.modalCard}>
|
||||
<Text style={s.modalTitle}>Neues Projekt</Text>
|
||||
<TextInput
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
placeholder="Name (z.B. 'Frankreich-Urlaub')"
|
||||
placeholderTextColor="#555570"
|
||||
style={s.input}
|
||||
autoFocus
|
||||
/>
|
||||
<TextInput
|
||||
value={newDesc}
|
||||
onChangeText={setNewDesc}
|
||||
placeholder="Beschreibung — kurz, hilft beim Wiederfinden"
|
||||
placeholderTextColor="#555570"
|
||||
style={[s.input, { height: 70 }]}
|
||||
multiline
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||||
<TouchableOpacity onPress={() => setNewOpen(false)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
||||
<Text style={s.modalBtnText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={createProject} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
|
||||
<Text style={s.modalBtnText}>Anlegen + aktivieren</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={() => setEditing(null)}>
|
||||
<View style={s.modalOverlay}>
|
||||
<View style={s.modalCard}>
|
||||
<Text style={s.modalTitle}>Projekt bearbeiten</Text>
|
||||
<TextInput
|
||||
value={editName}
|
||||
onChangeText={setEditName}
|
||||
placeholder="Name"
|
||||
placeholderTextColor="#555570"
|
||||
style={s.input}
|
||||
/>
|
||||
<TextInput
|
||||
value={editDesc}
|
||||
onChangeText={setEditDesc}
|
||||
placeholder="Beschreibung"
|
||||
placeholderTextColor="#555570"
|
||||
style={[s.input, { height: 70 }]}
|
||||
multiline
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||||
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
||||
<Text style={s.modalBtnText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={saveEdit} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
|
||||
<Text style={s.modalBtnText}>Speichern</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{editing && editing.status !== 'ended' && (
|
||||
<TouchableOpacity onPress={() => endProject(editing)} style={s.tertiaryBtn}>
|
||||
<Text style={s.tertiaryBtnText}>⏹ Projekt beenden</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{editing && (
|
||||
<TouchableOpacity onPress={() => archiveProject(editing)} style={s.tertiaryBtn}>
|
||||
<Text style={[s.tertiaryBtnText, { color: '#E55C5C' }]}>🗑 Archivieren</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Wenn als Modal genutzt
|
||||
if (onClose) {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
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;
|
||||
@@ -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 {
|
||||
@@ -68,6 +70,9 @@ interface ChatMessage {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
attachments?: Attachment[];
|
||||
/** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
|
||||
* Projekt-Bloecken zu gruppieren (auf/einklappbar). */
|
||||
projectId?: string;
|
||||
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
|
||||
messageId?: string;
|
||||
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
|
||||
@@ -280,6 +285,16 @@ const ChatScreen: React.FC = () => {
|
||||
const [showJumpDown, setShowJumpDown] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchVisible, setSearchVisible] = useState(false);
|
||||
const [projectsVisible, setProjectsVisible] = useState(false);
|
||||
// Focus-One-View: welchen Chat sieht Stefan gerade?
|
||||
// Leer = Hauptchat, sonst die project_id. Multi-Threading:
|
||||
// Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
|
||||
// die laufen im Brain weiter, wir sehen sie hier nur nicht.
|
||||
const [focusedProjectId, setFocusedProjectId] = useState<string>('');
|
||||
// Lookup-Map id → Projekt (fuer Drawer + Referenzen)
|
||||
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
|
||||
// Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
|
||||
const [queueStatus, setQueueStatus] = useState<Record<string, { busy: boolean; queue_size: number }>>({});
|
||||
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: ''});
|
||||
@@ -456,7 +471,53 @@ const ChatScreen: React.FC = () => {
|
||||
}, [dispatchWithAck]);
|
||||
|
||||
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
||||
// sofort greift, ohne Context- oder Event-System)
|
||||
// Projekt-Namen laden (Lookup-Map) + focusedProjectId aus AsyncStorage
|
||||
// wiederherstellen (Default = Hauptchat wenn nichts gespeichert). Der
|
||||
// Brain hat mit Multi-Threading keinen global-aktiven Projekt-State mehr;
|
||||
// Focus ist reine App-lokale UI-Info.
|
||||
useEffect(() => {
|
||||
const loadNames = () => {
|
||||
brainApi.listProjects(true)
|
||||
.then(list => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of list) map[p.id] = p.name;
|
||||
setProjectNameById(prev => ({ ...prev, ...map }));
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
loadNames();
|
||||
// Letzten Focus aus Storage restoren
|
||||
AsyncStorage.getItem('aria_focused_project_id').then(v => {
|
||||
if (v && typeof v === 'string') setFocusedProjectId(v);
|
||||
}).catch(() => {});
|
||||
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadNames(); });
|
||||
return () => unsub();
|
||||
}, []);
|
||||
|
||||
// Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder
|
||||
// da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
|
||||
// fuer den Auto-Fall angenehm.
|
||||
useEffect(() => {
|
||||
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
|
||||
}, [focusedProjectId]);
|
||||
|
||||
// Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und
|
||||
// fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
if (rvs.getState() !== 'connected') return;
|
||||
try {
|
||||
const s = await brainApi.getProjectQueueStatus();
|
||||
if (cancelled) return;
|
||||
setQueueStatus(s.contexts || {});
|
||||
} catch {}
|
||||
};
|
||||
poll();
|
||||
const iv = setInterval(poll, 2000);
|
||||
return () => { cancelled = true; clearInterval(iv); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
||||
@@ -740,6 +801,7 @@ const ChatScreen: React.FC = () => {
|
||||
timestamp: m.ts || Date.now(),
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
|
||||
projectId: typeof m.project_id === 'string' ? m.project_id : '',
|
||||
...(cmid && { clientMsgId: cmid }),
|
||||
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
|
||||
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
|
||||
@@ -795,6 +857,23 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// project_changed: ARIA hat via Tool ein Projekt erstellt/betreten/exited/beendet.
|
||||
// App entscheidet ob sie den Focus wechselt basierend auf action + payload.
|
||||
if (message.type === 'project_changed') {
|
||||
const p: any = message.payload || {};
|
||||
const action = p.action || '';
|
||||
// Neuer Projekt-Name in Lookup-Map merken
|
||||
if (p.id && p.name) {
|
||||
setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
|
||||
}
|
||||
if (action === 'entered' || action === 'created') {
|
||||
if (p.id) setFocusedProjectId(p.id);
|
||||
} else if (action === 'exited') {
|
||||
setFocusedProjectId('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'skill_created') {
|
||||
const p = (message.payload || {}) as any;
|
||||
const skillMsg: ChatMessage = {
|
||||
@@ -1046,6 +1125,7 @@ const ChatScreen: React.FC = () => {
|
||||
attachments: message.payload.attachments as Attachment[] | undefined,
|
||||
messageId: (message.payload.messageId as string) || undefined,
|
||||
backupTs: (message.payload.backupTs as number) || undefined,
|
||||
projectId: ((message.payload as any).projectId as string) || '',
|
||||
};
|
||||
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
|
||||
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
|
||||
@@ -1588,7 +1668,17 @@ const ChatScreen: React.FC = () => {
|
||||
() => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated),
|
||||
[messages],
|
||||
);
|
||||
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
|
||||
|
||||
// Focus-One-View (Multi-Threading, 06/2026): Chat zeigt NUR die Nachrichten
|
||||
// des gerade fokussierten Kontexts. Hauptchat (focusedProjectId leer) →
|
||||
// alle ungeтагtgeд Nachrichten. Projekt X aktiv → nur Nachrichten mit
|
||||
// projectId === X. ARIA arbeitet weiterhin in allen Kontexten parallel;
|
||||
// wir sehen nur den einen.
|
||||
const messagesForRender = useMemo(() => {
|
||||
return chatVisibleMessages.filter(m => (m.projectId || '') === focusedProjectId);
|
||||
}, [chatVisibleMessages, focusedProjectId]);
|
||||
|
||||
const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]);
|
||||
|
||||
// Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
|
||||
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
|
||||
@@ -1790,6 +1880,7 @@ const ChatScreen: React.FC = () => {
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const cmid = nextClientMsgId();
|
||||
const activePid = focusedProjectId;
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
@@ -1798,16 +1889,18 @@ const ChatScreen: React.FC = () => {
|
||||
clientMsgId: cmid,
|
||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||
sendAttempts: 1,
|
||||
projectId: activePid,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
|
||||
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
|
||||
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
|
||||
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
|
||||
dispatchWithAck(cmid, 'chat', {
|
||||
text,
|
||||
voice: localXttsVoiceRef.current,
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: wasInterrupted,
|
||||
projectId: activePid,
|
||||
...(location && { location }),
|
||||
});
|
||||
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
|
||||
@@ -2457,6 +2550,66 @@ const ChatScreen: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
|
||||
{/* Focus-Indicator + Drawer-Toggle. Multi-Threading: das ist reine
|
||||
Anzeige „was sehe ich gerade" — ARIA arbeitet gleichzeitig in
|
||||
allen Kontexten weiter, wir zeigen hier nur einen. */}
|
||||
{(() => {
|
||||
const isMain = !focusedProjectId;
|
||||
const focusedName = isMain ? '' : (projectNameById[focusedProjectId] || focusedProjectId);
|
||||
const focusedQueue = queueStatus[isMain ? '__main__' : focusedProjectId];
|
||||
const dot = focusedQueue?.busy
|
||||
? { color: '#FF6E6E', label: 'arbeitet' }
|
||||
: focusedQueue?.queue_size
|
||||
? { color: '#FFD60A', label: `Queue: ${focusedQueue.queue_size}` }
|
||||
: { color: '#34C759', label: 'idle' };
|
||||
// Anzahl anderer Kontexte die gerade aktiv sind (fuer Drawer-Badge)
|
||||
const otherActive = Object.entries(queueStatus).filter(([k, v]) => {
|
||||
const kFocus = isMain ? '__main__' : focusedProjectId;
|
||||
if (k === kFocus) return false;
|
||||
return v.busy || v.queue_size > 0;
|
||||
}).length;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 12, paddingVertical: 8,
|
||||
backgroundColor: isMain ? '#1A1A26' : 'rgba(52,199,89,0.10)',
|
||||
borderBottomWidth: 2,
|
||||
borderColor: isMain ? '#1E1E2E' : '#34C759',
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => setProjectsVisible(true)}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}
|
||||
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||
>
|
||||
<Text style={{ fontSize: 20 }}>☰</Text>
|
||||
{otherActive > 0 && (
|
||||
<View style={{ backgroundColor: '#FF6E6E', borderRadius: 8, minWidth: 16, height: 16, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{otherActive}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View style={{ flex: 1, marginLeft: 10, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Text style={{ fontSize: 14, color: isMain ? '#E0E0F0' : '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
|
||||
{isMain ? '💬 Hauptchat' : `📁 ${focusedName}`}
|
||||
</Text>
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||
<Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Projekt-Drawer als Modal */}
|
||||
<ProjectsBrowser
|
||||
visible={projectsVisible}
|
||||
onClose={() => setProjectsVisible(false)}
|
||||
onActiveChanged={(p) => setFocusedProjectId(p?.id || '')}
|
||||
queueStatus={queueStatus}
|
||||
/>
|
||||
|
||||
{/* Suchleiste mit Treffer-Navigation */}
|
||||
{searchVisible && (
|
||||
<View style={styles.searchBar}>
|
||||
|
||||
@@ -92,6 +92,8 @@ 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 brainApi from '../services/brainApi';
|
||||
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
@@ -142,6 +144,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' },
|
||||
@@ -202,7 +205,9 @@ const SettingsScreen: React.FC = () => {
|
||||
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
|
||||
// Datei-Manager
|
||||
const [fileManagerOpen, setFileManagerOpen] = useState(false);
|
||||
const [fileManagerFiles, setFileManagerFiles] = useState<Array<{name: string; path: string; size: number; mtime: number; fromAria: boolean}>>([]);
|
||||
const [fileManagerFiles, setFileManagerFiles] = useState<Array<{name: string; path: string; size: number; mtime: number; fromAria: boolean; projectId?: string}>>([]);
|
||||
const [fileFilterProjectId, setFileFilterProjectId] = useState<string>('__all__');
|
||||
const [fileFilterProjects, setFileFilterProjects] = useState<Array<{id: string; name: string}>>([]);
|
||||
const [fileManagerLoading, setFileManagerLoading] = useState(false);
|
||||
const [fileManagerError, setFileManagerError] = useState('');
|
||||
const [fileManagerSearch, setFileManagerSearch] = useState('');
|
||||
@@ -724,6 +729,20 @@ const SettingsScreen: React.FC = () => {
|
||||
return () => unsub();
|
||||
}, [fileManagerOpen]);
|
||||
|
||||
// Beim Oeffnen des Datei-Managers: Projekt-Liste laden fuer den Filter.
|
||||
useEffect(() => {
|
||||
if (!fileManagerOpen) return;
|
||||
brainApi.listProjects(true)
|
||||
.then(list => setFileFilterProjects(list.map(p => ({ id: p.id, name: p.name }))))
|
||||
.catch(() => {});
|
||||
// Default-Filter: fokussiertes Projekt aus AsyncStorage (falls Stefan
|
||||
// grade in einem drin ist), sonst "alle". Multi-Threading: Focus ist
|
||||
// App-lokal, kein Brain-Query mehr.
|
||||
AsyncStorage.getItem('aria_focused_project_id')
|
||||
.then(pid => { if (pid) setFileFilterProjectId(pid); })
|
||||
.catch(() => {});
|
||||
}, [fileManagerOpen]);
|
||||
|
||||
// --- QR-Code scannen ---
|
||||
|
||||
const openQRScanner = useCallback(() => {
|
||||
@@ -960,6 +979,29 @@ const SettingsScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
{/* Projekt-Filter: scrollbare Pill-Reihe. „Alle Projekte" + „Hauptchat" +
|
||||
ein Pill pro Projekt. Default = aktives Projekt (siehe useEffect oben). */}
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||
style={{marginTop:6}} contentContainerStyle={{gap:6, paddingRight:8}}>
|
||||
{[
|
||||
{ id: '__all__', name: '📁 Alle Projekte' },
|
||||
{ id: '', name: '💬 Hauptchat' },
|
||||
...fileFilterProjects,
|
||||
].map(p => (
|
||||
<TouchableOpacity
|
||||
key={p.id || 'mainchat'}
|
||||
onPress={() => setFileFilterProjectId(p.id)}
|
||||
style={{
|
||||
paddingVertical:6, paddingHorizontal:12, borderRadius:14,
|
||||
backgroundColor: fileFilterProjectId === p.id ? '#34C759' : '#1E1E2E',
|
||||
}}
|
||||
>
|
||||
<Text style={{color: fileFilterProjectId === p.id ? '#fff' : '#8888AA', fontSize:12}}>
|
||||
{p.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{fileManagerLoading ? (
|
||||
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
|
||||
@@ -970,6 +1012,11 @@ const SettingsScreen: React.FC = () => {
|
||||
let files = fileManagerFiles;
|
||||
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
|
||||
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
|
||||
// Projekt-Filter: '__all__' = alles, '' = Hauptchat (kein project_id),
|
||||
// sonst exakte project_id-Match.
|
||||
if (fileFilterProjectId !== '__all__') {
|
||||
files = files.filter(f => (f.projectId || '') === fileFilterProjectId);
|
||||
}
|
||||
if (fileManagerSearch) {
|
||||
const q = fileManagerSearch.toLowerCase();
|
||||
files = files.filter(f => f.name.toLowerCase().includes(q));
|
||||
@@ -1280,7 +1327,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 +2236,18 @@ const SettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Projekte === */}
|
||||
{currentSection === 'projects' && (<>
|
||||
<Text style={styles.sectionTitle}>Projekte</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||
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".
|
||||
</Text>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<ProjectsBrowser />
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Gedaechtnis === */}
|
||||
{currentSection === 'memory' && (<>
|
||||
<Text style={styles.sectionTitle}>Gedächtnis</Text>
|
||||
|
||||
@@ -151,6 +151,34 @@ 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[];
|
||||
}
|
||||
|
||||
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
|
||||
* Key "__main__" = Hauptchat, sonst project_id. */
|
||||
export interface QueueContextStatus {
|
||||
busy: boolean;
|
||||
queue_size: number;
|
||||
}
|
||||
export interface ProjectQueueStatus {
|
||||
contexts: Record<string, QueueContextStatus>;
|
||||
}
|
||||
|
||||
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||
export interface Skill {
|
||||
name: string;
|
||||
@@ -521,6 +549,64 @@ export const brainApi = {
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── Projekte ───────────────────────────────────────────────────
|
||||
|
||||
/** Kompletter Status: aktives Projekt + Liste. */
|
||||
getProjectStatus(): Promise<ProjectStatus> {
|
||||
return _send('/projects/status');
|
||||
},
|
||||
|
||||
/** Nur die Liste — fuer Sidebar/Drawer. */
|
||||
listProjects(includeArchived: boolean = false): Promise<Project[]> {
|
||||
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<Project> {
|
||||
return _send('/projects/create', {
|
||||
method: 'POST',
|
||||
body: { description: '', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Aktives Projekt wechseln. Leerer projectId = Hauptthread. */
|
||||
switchProject(projectId: string): Promise<ProjectStatus> {
|
||||
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<Project> {
|
||||
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<Pick<Project, 'name' | 'description'>>): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
});
|
||||
},
|
||||
|
||||
/** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
|
||||
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
|
||||
* Wird fuer Status-Dots im Drawer periodisch gepollt. */
|
||||
getProjectQueueStatus(): Promise<ProjectQueueStatus> {
|
||||
return _send('/projects/queue-status');
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
|
||||
+282
-9
@@ -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,110 @@ 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": (
|
||||
"Signalisiert der App/Diagnostic 'wechsel zu diesem Projekt'. Fuzzy-"
|
||||
"Match auf Namen — 'Spotify' findet das Projekt 'Spotify-Setup'. "
|
||||
"Der AKTUELLE Turn bleibt aber in seinem Chat-Kontext — wir haben "
|
||||
"Multi-Threading, kein globales 'aktives Projekt' mehr. Wenn Stefan "
|
||||
"im Hauptchat sagt 'lass uns in Spotify weiter machen': "
|
||||
"project_enter aufrufen (App wechselt Ansicht), aber Deine Antwort "
|
||||
"geht trotzdem im Hauptchat raus. 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": (
|
||||
"Signalisiert der App/Diagnostic 'wechsel zurueck zum Hauptchat'. "
|
||||
"Nutze wenn Stefan sagt 'Projekt Ende' oder 'zurueck zum Hauptchat' "
|
||||
"waehrend er visuell in einem Projekt ist. Der aktuelle Turn bleibt "
|
||||
"in seinem Chat-Kontext — Multi-Threading."
|
||||
),
|
||||
"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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -952,7 +1057,21 @@ class Agent:
|
||||
|
||||
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
||||
|
||||
def chat(self, user_message: str, source: str = "") -> str:
|
||||
def chat(self, user_message: str, source: str = "",
|
||||
project_id: Optional[str] = None,
|
||||
pending_queue: Optional[list[str]] = None) -> str:
|
||||
"""Verarbeitet eine User-Nachricht — pro Request project_id explizit
|
||||
angegeben (leer = Hauptchat). Kein globaler active_project-State mehr —
|
||||
so laufen parallele /chat-Requests fuer verschiedene Projekte echt
|
||||
parallel (Multi-Threading-Architektur seit 06/2026).
|
||||
|
||||
pending_queue: Liste weiterer User-Nachrichten die in DIESEM Projekt
|
||||
NACH dem aktuellen Turn warten. ARIA sieht sie im System-Prompt und
|
||||
soll pruefen ob eine spaetere Nachricht den aktuellen Task
|
||||
korrigiert / annuliert (dann Skip-Antwort statt Ausfuehren).
|
||||
|
||||
Wenn project_id=None (Backward-Compat fuer Aufrufer die den Param nicht
|
||||
setzen): wird als Hauptchat behandelt."""
|
||||
user_message = (user_message or "").strip()
|
||||
if not user_message:
|
||||
raise ValueError("Leere Nachricht")
|
||||
@@ -960,17 +1079,27 @@ class Agent:
|
||||
# Events vom letzten Turn weglassen
|
||||
self._pending_events = []
|
||||
|
||||
# Projekt-Kontext pro Request statt aus globalem State
|
||||
active_project_id = (project_id or "").strip()
|
||||
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 +1146,60 @@ class Agent:
|
||||
oauth_callback_host=oauth_host,
|
||||
oauth_callback_port=oauth_port,
|
||||
oauth_callback_tls=oauth_tls)
|
||||
# Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
|
||||
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
|
||||
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
|
||||
if pending_queue:
|
||||
queue_lines = "\n".join(f" - {m[:280]}" for m in pending_queue[:5])
|
||||
more_hint = ""
|
||||
if len(pending_queue) > 5:
|
||||
more_hint = f"\n ... und {len(pending_queue) - 5} weitere"
|
||||
system_prompt += (
|
||||
f"\n\n## QUEUE — NACH DIESEM TASK WARTEN\n"
|
||||
f"{queue_lines}{more_hint}\n"
|
||||
f"\nBEVOR DU DEN AKTUELLEN TASK LOESST:\n"
|
||||
f" 1. Pruefe die Queue oben — widerspricht/annuliert eine der spaeteren "
|
||||
f"Nachrichten den aktuellen Task?\n"
|
||||
f" 2. Wenn ja: antworte ganz kurz 'Task ubersprungen — wird durch spaetere "
|
||||
f"Nachricht korrigiert' und mach KEINE Aktion. Der spaetere Task laeuft dann "
|
||||
f"ganz normal als naechste Anfrage durch.\n"
|
||||
f" 3. Wenn nein / unabhaengige Ergaenzung: Task normal loesen.\n"
|
||||
f"Beispiel: aktueller Task 'titelleiste rot', Queue enthaelt "
|
||||
f"'doch nicht, mach sie blau' → skip, blau kommt als naechste Anfrage."
|
||||
)
|
||||
|
||||
# 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 +1258,17 @@ 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)
|
||||
# Turn-Kontext bleibt gleich — es gibt keinen globalen Wechsel
|
||||
# mehr, jeder Request laeuft in seinem eigenen project_id-Kontext.
|
||||
self.conversation.add("assistant", err_text,
|
||||
project_id=active_project_id)
|
||||
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)
|
||||
self.conversation.add("assistant", final_reply,
|
||||
project_id=active_project_id)
|
||||
return final_reply
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
@@ -1648,6 +1828,99 @@ 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']}). App/Diagnostic "
|
||||
f"kriegen ein project_changed-Event und koennen dahin wechseln. "
|
||||
f"Kommender Turn bleibt aber im aktuellen Chat-Kontext — "
|
||||
f"Multi-Threading, jeder Chat ist eigenstaendig.")
|
||||
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."
|
||||
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 — App/Diagnostic wird zum Projekt '{p['name']}' "
|
||||
f"(id={p['id']}, {turn_count} bisherige Turns) umschalten. "
|
||||
f"Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.{hint}")
|
||||
if name == "project_exit":
|
||||
self._pending_events.append({
|
||||
"type": "project_changed",
|
||||
"project": None,
|
||||
"action": "exited",
|
||||
})
|
||||
return ("OK — App/Diagnostic bekommt Signal 'zurueck zum Hauptchat'. "
|
||||
"Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.")
|
||||
if name == "project_list":
|
||||
items = projects_mod.list_projects()
|
||||
if not items:
|
||||
return "(keine Projekte angelegt)"
|
||||
lines = []
|
||||
for p in items:
|
||||
status_lbl = p.get("status", "active")
|
||||
lines.append(
|
||||
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
|
||||
f"status={status_lbl})"
|
||||
)
|
||||
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)
|
||||
|
||||
+38
-10
@@ -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)
|
||||
|
||||
+189
-19
@@ -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")
|
||||
@@ -606,6 +607,11 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
||||
class ChatIn(BaseModel):
|
||||
message: str
|
||||
source: str = "" # "app" / "diagnostic" / "stt" — optional
|
||||
# Multi-Threading: Client bestimmt pro Request welches Projekt (leer = Hauptchat).
|
||||
# Kein globaler active_project-State mehr im Brain — parallele Requests fuer
|
||||
# verschiedene Projekte laufen echt parallel, nur Requests fuers gleiche
|
||||
# Projekt queuen (per-Projekt-Lock).
|
||||
project_id: str = ""
|
||||
|
||||
|
||||
class ChatOut(BaseModel):
|
||||
@@ -613,30 +619,194 @@ class ChatOut(BaseModel):
|
||||
turns: int
|
||||
distilling: bool
|
||||
events: list = Field(default_factory=list)
|
||||
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||
# UI landet.
|
||||
project_id: str = ""
|
||||
|
||||
|
||||
# Per-Projekt async-Locks fuer Queue-Behavior: Requests fuers gleiche Projekt
|
||||
# warten aufeinander (queue), Requests fuer verschiedene Projekte laufen echt
|
||||
# parallel. Hauptchat = Lock unter key "" (leerer String).
|
||||
_project_locks: dict[str, asyncio.Lock] = {}
|
||||
_project_locks_meta_lock = asyncio.Lock()
|
||||
# Pro Projekt eine Liste noch-nicht-verarbeiteter Requests. Wird beim Enqueue
|
||||
# ergaenzt, beim Fertig-Werden gepoppt. Ermoeglicht Queue-Aware-Prompting:
|
||||
# waehrend ARIA an Task N arbeitet, sieht sie N+1..N+k als System-Prompt-Hinweis
|
||||
# und kann entscheiden ob eine spaetere Nachricht die aktuelle korrigiert/
|
||||
# annuliert → dann Skip-Antwort statt Ausfuehren.
|
||||
_project_pending: dict[str, list[dict]] = {}
|
||||
|
||||
|
||||
async def _get_project_lock(project_id: str) -> asyncio.Lock:
|
||||
"""Holt (oder erzeugt) den asyncio.Lock fuer ein bestimmtes Projekt.
|
||||
Nutzt _project_locks_meta_lock zur Vermeidung von Race Conditions
|
||||
beim ersten-Zugriff pro Projekt."""
|
||||
async with _project_locks_meta_lock:
|
||||
lock = _project_locks.get(project_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_project_locks[project_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _project_queue_snapshot() -> dict:
|
||||
"""Snapshot fuer /projects/queue-status: welche Projekte arbeiten gerade,
|
||||
wieviele wait-in-queue haben, welche sind idle."""
|
||||
out = {}
|
||||
# Zeige nur Kontexte mit Aktivitaet — locked oder pending
|
||||
seen: set = set()
|
||||
for pid, lock in _project_locks.items():
|
||||
pending = len(_project_pending.get(pid, []))
|
||||
is_busy = lock.locked()
|
||||
# busy: gerade in Verarbeitung. queue: N weitere warten dahinter.
|
||||
# Der Busy-Request zaehlt NICHT in queue (er ist ja aus pending schon "raus").
|
||||
out[pid or "__main__"] = {
|
||||
"busy": is_busy,
|
||||
"queue_size": max(0, pending - (1 if is_busy else 0)),
|
||||
}
|
||||
seen.add(pid)
|
||||
for pid, pend in _project_pending.items():
|
||||
if pid in seen:
|
||||
continue
|
||||
out[pid or "__main__"] = {"busy": False, "queue_size": len(pend)}
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/chat", response_model=ChatOut)
|
||||
def chat(body: ChatIn, background: BackgroundTasks):
|
||||
async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
"""Hauptpfad. Antwort kommt synchron. Memory-Destillat laeuft
|
||||
im Hintergrund nachdem die Response rausging."""
|
||||
a = agent()
|
||||
try:
|
||||
reply = a.chat(body.message, source=body.source)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
except RuntimeError as exc:
|
||||
logger.error("chat fehlgeschlagen: %s", exc)
|
||||
raise HTTPException(502, str(exc))
|
||||
im Hintergrund nachdem die Response rausging.
|
||||
|
||||
needs_distill = a.conversation.needs_distill()
|
||||
if needs_distill:
|
||||
background.add_task(a.distill_old_turns)
|
||||
return ChatOut(
|
||||
reply=reply,
|
||||
turns=len(a.conversation.turns),
|
||||
distilling=needs_distill,
|
||||
events=a.pop_events(),
|
||||
)
|
||||
Multi-Threading: Requests fuers gleiche Projekt (project_id gleich)
|
||||
laufen serialisiert durch den per-Projekt-Lock — Queue-Behavior.
|
||||
Verschiedene Projekte laufen parallel."""
|
||||
pid = (body.project_id or "").strip()
|
||||
lock = await _get_project_lock(pid)
|
||||
# Vor dem Lock in die Pending-Liste, damit die verlaufende Task sehen kann
|
||||
# was NACH ihr in der Warteschlange steht (Queue-Aware Prompting).
|
||||
import uuid as _uuid
|
||||
req_id = _uuid.uuid4().hex
|
||||
_project_pending.setdefault(pid, []).append({
|
||||
"id": req_id, "message": body.message, "source": body.source,
|
||||
})
|
||||
try:
|
||||
async with lock:
|
||||
# Snapshot: was liegt NACH mir in der Queue?
|
||||
after_me = [
|
||||
e["message"] for e in _project_pending.get(pid, [])
|
||||
if e["id"] != req_id
|
||||
]
|
||||
a = agent()
|
||||
try:
|
||||
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
||||
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
||||
loop = asyncio.get_running_loop()
|
||||
reply = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: a.chat(
|
||||
body.message, source=body.source, project_id=pid,
|
||||
pending_queue=after_me,
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
except RuntimeError as exc:
|
||||
logger.error("chat fehlgeschlagen: %s", exc)
|
||||
raise HTTPException(502, str(exc))
|
||||
|
||||
needs_distill = a.conversation.needs_distill()
|
||||
if needs_distill:
|
||||
background.add_task(a.distill_old_turns)
|
||||
return ChatOut(
|
||||
reply=reply,
|
||||
turns=len(a.conversation.turns),
|
||||
distilling=needs_distill,
|
||||
events=a.pop_events(),
|
||||
project_id=pid,
|
||||
)
|
||||
finally:
|
||||
_project_pending[pid] = [
|
||||
e for e in _project_pending.get(pid, []) if e["id"] != req_id
|
||||
]
|
||||
|
||||
|
||||
@app.get("/projects/queue-status")
|
||||
def projects_queue_status():
|
||||
"""Snapshot: fuer jeden Projekt-Kontext (inkl. Hauptchat unter __main__)
|
||||
- busy: True wenn gerade ein Request in Verarbeitung
|
||||
- queue_size: wieviele weitere warten dahinter"""
|
||||
return {"contexts": _project_queue_snapshot()}
|
||||
|
||||
|
||||
# ── 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")
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
+214
-7
@@ -611,6 +611,13 @@ class ARIABridge:
|
||||
self._last_chat_final_at: float = 0.0
|
||||
# requestId → messageId Map fuer XTTS-Audio-Cache (App-seitige Zuordnung)
|
||||
self._xtts_request_to_message: dict[str, str] = {}
|
||||
# Voice-Router (Multi-Threading, 06/2026): sticky Projekt-Kontext fuer
|
||||
# STT-Voice-Nachrichten. Wechselt via „fuer <name>:"-Prefix, faellt nach
|
||||
# STICKY_TIMEOUT_SEC ohne neue Voice-Message zurueck auf Hauptchat.
|
||||
# Meta-Kommandos („zurueck zum hauptchat") werden client-seitig
|
||||
# interceptiert und aendern hier den Sticky OHNE Brain-Roundtrip.
|
||||
self._voice_sticky_project_id: str = ""
|
||||
self._voice_sticky_expires_at: float = 0.0
|
||||
# Voice-Override aus letzter Chat-Nachricht einer App.
|
||||
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
||||
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
||||
@@ -1005,6 +1012,50 @@ class ARIABridge:
|
||||
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
||||
return cleaned, files, missing
|
||||
|
||||
def _tag_file_to_project(self, file_path: str, project_id: str) -> None:
|
||||
"""Schreibt file_path → project_id in /shared/config/file_projects.json.
|
||||
Best-effort, fail-silent. project_id leer = Eintrag entfernen (Hauptchat)."""
|
||||
try:
|
||||
manifest_path = "/shared/config/file_projects.json"
|
||||
os.makedirs("/shared/config", exist_ok=True)
|
||||
try:
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
if not isinstance(manifest, dict):
|
||||
manifest = {}
|
||||
except FileNotFoundError:
|
||||
manifest = {}
|
||||
except Exception:
|
||||
manifest = {}
|
||||
if project_id:
|
||||
manifest[file_path] = project_id
|
||||
else:
|
||||
manifest.pop(file_path, None)
|
||||
tmp = manifest_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp, manifest_path)
|
||||
logger.info("[file-project] %s → %s", file_path, project_id or "(main)")
|
||||
except Exception as exc:
|
||||
logger.warning("[file-project] tag failed (%s): %s", file_path, exc)
|
||||
|
||||
def _tag_file_to_active_project(self, file_path: str) -> None:
|
||||
"""Convenience: Brain nach aktivem Projekt fragen + taggen.
|
||||
Wird vom App-Upload-Handler genutzt (dort wissen wir die Projekt-ID
|
||||
noch nicht aus dem Payload — Stefan kann ja zwischen App-Upload und
|
||||
Chat-Send das Projekt gewechselt haben). ARIA-eigene Dateien gehen
|
||||
ueber _tag_file_to_project mit turn_project_id direkt."""
|
||||
try:
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
with urllib.request.urlopen(f"{brain_url}/projects/status", timeout=5) as r:
|
||||
data = json.loads(r.read())
|
||||
active_id = (data.get("active_id") or "").strip()
|
||||
if not active_id:
|
||||
return
|
||||
self._tag_file_to_project(file_path, active_id)
|
||||
except Exception as exc:
|
||||
logger.warning("[file-project] active-query failed (%s): %s", file_path, exc)
|
||||
|
||||
async def _broadcast_aria_file(self, file_info: dict) -> None:
|
||||
"""ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren."""
|
||||
logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)",
|
||||
@@ -1163,7 +1214,15 @@ class ARIABridge:
|
||||
# Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht
|
||||
# vorlesen) und parallel als file_from_aria-Event geschickt.
|
||||
text, aria_files, missing_files = self._extract_file_markers(text)
|
||||
# ARIA-Dateien dem aktiven Projekt zuordnen (falls eines aktiv war).
|
||||
# turn_project_id kommt vom Brain mit dem /chat-Response und reflektiert
|
||||
# den Stand NACH dem Turn — passt fuer Dateien die ARIA waehrend des
|
||||
# Turns geschrieben hat (sie sind „im selben Projekt entstanden").
|
||||
turn_pid = (payload.get("projectId") or "").strip() if isinstance(payload, dict) else ""
|
||||
for f in aria_files:
|
||||
server_path = f.get("serverPath")
|
||||
if turn_pid and server_path:
|
||||
self._tag_file_to_project(server_path, turn_pid)
|
||||
await self._broadcast_aria_file(f)
|
||||
# Bei fehlenden Files: User informieren (sonst sieht er nur stille
|
||||
# Verluste — ARIA hat den Marker hingeschrieben aber das File nicht
|
||||
@@ -1185,6 +1244,7 @@ class ARIABridge:
|
||||
"text": display_text,
|
||||
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||
"project_id": turn_pid,
|
||||
})
|
||||
|
||||
metadata = payload.get("metadata", {})
|
||||
@@ -1224,6 +1284,9 @@ class ARIABridge:
|
||||
"backupTs": assistant_backup_ts,
|
||||
# Debug: aufbereiteter Text fuer TTS (App ignoriert, Diagnostic zeigt optional)
|
||||
"ttsText": tts_text_preview if tts_text_preview != text else "",
|
||||
# Projekt-Zuordnung — App + Diagnostic sortieren die Bubble in
|
||||
# den passenden Projekt-Block. Leer = Hauptchat.
|
||||
"projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "",
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
@@ -1454,7 +1517,9 @@ class ARIABridge:
|
||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
|
||||
return True
|
||||
|
||||
async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None:
|
||||
async def send_to_core(self, text: str, source: str = "bridge",
|
||||
client_msg_id: Optional[str] = None,
|
||||
project_id: str = "") -> None:
|
||||
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
|
||||
|
||||
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
|
||||
@@ -1464,8 +1529,13 @@ class ARIABridge:
|
||||
"""
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
url = f"{brain_url}/chat"
|
||||
payload = json.dumps({"message": text, "source": source}).encode("utf-8")
|
||||
logger.info("[brain] chat ← %s '%s'", source, text[:80])
|
||||
# project_id kommt jetzt IM /chat-Body an das Brain (Multi-Threading:
|
||||
# per-Request-Routing statt globaler active_project-State).
|
||||
payload = json.dumps({
|
||||
"message": text, "source": source,
|
||||
"project_id": project_id or "",
|
||||
}).encode("utf-8")
|
||||
logger.info("[brain] chat ← %s '%s' project=%s", source, text[:80], project_id or "(main)")
|
||||
|
||||
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
|
||||
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
|
||||
@@ -1474,6 +1544,8 @@ class ARIABridge:
|
||||
entry: dict = {"role": "user", "text": text, "source": source}
|
||||
if client_msg_id:
|
||||
entry["clientMsgId"] = client_msg_id
|
||||
if project_id:
|
||||
entry["project_id"] = project_id
|
||||
self._append_chat_backup(entry)
|
||||
|
||||
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
||||
@@ -1521,6 +1593,11 @@ class ARIABridge:
|
||||
await self._emit_activity("idle", "")
|
||||
return
|
||||
|
||||
# Projekt-Kontext des Turns — wird an _process_core_response weiter-
|
||||
# gegeben damit der chat-Broadcast die Bubble dem richtigen Projekt-
|
||||
# Block in App + Diagnostic zuordnen kann.
|
||||
turn_project_id = (data.get("project_id") or "").strip()
|
||||
|
||||
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
||||
# damit sie in der UI vor der Reply auftauchen
|
||||
for event in data.get("events", []) or []:
|
||||
@@ -1564,6 +1641,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-
|
||||
@@ -1572,7 +1663,7 @@ class ARIABridge:
|
||||
# passend behandelt wird (hier minimal, weil Brain noch keine
|
||||
# metadata mitschickt).
|
||||
try:
|
||||
await self._process_core_response(reply, {})
|
||||
await self._process_core_response(reply, {"projectId": turn_project_id})
|
||||
except Exception:
|
||||
logger.exception("[brain] _process_core_response Fehler")
|
||||
await self._emit_activity("idle", "")
|
||||
@@ -1883,6 +1974,7 @@ class ARIABridge:
|
||||
core_text,
|
||||
source="app" + (" [barge-in]" if interrupted else ""),
|
||||
client_msg_id=client_msg_id,
|
||||
project_id=str(payload.get("projectId") or ""),
|
||||
))
|
||||
return
|
||||
|
||||
@@ -2111,6 +2203,10 @@ class ARIABridge:
|
||||
f.write(base64.b64decode(file_b64))
|
||||
size_kb = len(file_b64) // 1365
|
||||
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
|
||||
# Datei dem aktuellen Projekt zuordnen (falls Stefan in einem ist).
|
||||
# Manifest in /shared/config/file_projects.json — File-Manager
|
||||
# in App + Diagnostic filtert danach.
|
||||
self._tag_file_to_active_project(file_path)
|
||||
|
||||
# Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
|
||||
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).
|
||||
@@ -2887,6 +2983,81 @@ class ARIABridge:
|
||||
else:
|
||||
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
||||
|
||||
# Voice-Router-Konstanten
|
||||
_VOICE_STICKY_TIMEOUT_SEC = 30.0
|
||||
_VOICE_META_BACK_TO_MAIN = re.compile(
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:zur(?:ü|ue)ck\s+zum\s+hauptchat|hauptchat\s+bitte|aria\s+hauptchat)\s*[.!?]?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VOICE_META_PROJECT_PREFIX = re.compile(
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
def _apply_voice_router(self, text: str) -> tuple[bool, str, str, str]:
|
||||
"""Voice-Router: entscheidet ob ein STT-Text ans Brain geht und wenn ja
|
||||
an welchen Projekt-Kontext.
|
||||
|
||||
Returns (should_forward, cleaned_text, project_id, meta_action):
|
||||
- should_forward=False: reines Meta-Kommando, kein Brain-Call.
|
||||
meta_action beschreibt was passiert ist (broadcastet an UI).
|
||||
- should_forward=True: cleaned_text ans Brain, project_id ist Focus.
|
||||
Bei Prefix wird der Prefix aus dem Text entfernt.
|
||||
|
||||
Sticky-Logik: nach einem projekt-getaggten Voice-Turn wird der Sticky
|
||||
30s lang gehalten. Innerhalb dieses Fensters gehen weitere Voice-Msgs
|
||||
OHNE Prefix in dasselbe Projekt. Nach Ablauf: Default Hauptchat.
|
||||
"""
|
||||
import time as _time
|
||||
now = _time.time()
|
||||
stripped = text.strip()
|
||||
|
||||
# 1) Meta: zurueck zum Hauptchat
|
||||
if self._VOICE_META_BACK_TO_MAIN.match(stripped):
|
||||
self._voice_sticky_project_id = ""
|
||||
self._voice_sticky_expires_at = 0.0
|
||||
return (False, "", "", "back_to_main")
|
||||
|
||||
# 2) Prefix: "fuer <name>: <text>"
|
||||
m = self._VOICE_META_PROJECT_PREFIX.match(stripped)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
remainder = m.group(2).strip()
|
||||
# Fuzzy-Match auf Projekt via Brain-API
|
||||
try:
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
with urllib.request.urlopen(f"{brain_url}/projects/list", timeout=3) as r:
|
||||
projects = json.loads(r.read()).get("projects", [])
|
||||
from difflib import SequenceMatcher
|
||||
best, best_score = None, 0.0
|
||||
q = name.lower()
|
||||
for p in projects:
|
||||
pname = p.get("name", "").lower()
|
||||
if q == pname or q == p.get("id", ""):
|
||||
best, best_score = p, 1.0
|
||||
break
|
||||
s = SequenceMatcher(None, q, pname).ratio()
|
||||
if s > best_score:
|
||||
best, best_score = p, s
|
||||
if best and best_score >= 0.6:
|
||||
pid = best["id"]
|
||||
self._voice_sticky_project_id = pid
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
logger.info("[voice-router] Prefix → Projekt '%s' (id=%s, score=%.2f)",
|
||||
best.get("name"), pid, best_score)
|
||||
return (True, remainder or stripped, pid, "project_prefix")
|
||||
except Exception as exc:
|
||||
logger.warning("[voice-router] Prefix-Match fehlgeschlagen: %s", exc)
|
||||
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
||||
# 3) Kein Meta / Prefix → Sticky oder Default
|
||||
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
||||
# Sticky refreshen
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
return (True, stripped, self._voice_sticky_project_id, "sticky")
|
||||
# Sticky abgelaufen — zurücksetzen
|
||||
self._voice_sticky_project_id = ""
|
||||
return (True, stripped, "", "default")
|
||||
|
||||
async def _process_endpoint_text(self, text: str,
|
||||
interrupted: bool = False,
|
||||
audio_request_id: str = "",
|
||||
@@ -2898,16 +3069,51 @@ class ARIABridge:
|
||||
Spiegel-Methode zu _process_app_audio NACH dem STT-Schritt. Bewusst
|
||||
eigene Methode statt Code-Pfade in _process_app_audio aufdroeseln,
|
||||
damit der Legacy-Pfad (App schickt 'audio') unangetastet bleibt.
|
||||
|
||||
Voice-Router: interceptiert Meta-Kommandos (zurueck zum Hauptchat)
|
||||
+ Prefix-Adressierung („fuer Frankreich: ...") + 30s-Sticky. Meta
|
||||
selbst geht NICHT ans Brain, sondern broadcastet als project_changed-
|
||||
Event → App+Diagnostic wechseln den Focus.
|
||||
"""
|
||||
should_forward, cleaned, project_id, meta_action = self._apply_voice_router(text)
|
||||
|
||||
if meta_action in ("back_to_main", "project_prefix"):
|
||||
# UI-Focus-Update broadcasten
|
||||
payload = {"action": "entered" if meta_action == "project_prefix" else "exited"}
|
||||
if meta_action == "project_prefix" and project_id:
|
||||
# Namen aus dem Cache holen — best effort
|
||||
try:
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
with urllib.request.urlopen(f"{brain_url}/projects/list", timeout=2) as r:
|
||||
for p in json.loads(r.read()).get("projects", []):
|
||||
if p.get("id") == project_id:
|
||||
payload["id"] = project_id
|
||||
payload["name"] = p.get("name", "")
|
||||
break
|
||||
except Exception:
|
||||
payload["id"] = project_id
|
||||
await self._send_to_rvs({
|
||||
"type": "project_changed",
|
||||
"payload": payload,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
|
||||
if not should_forward:
|
||||
logger.info("[voice-router] Meta-Kommando '%s' intercepted, kein Brain-Call",
|
||||
meta_action)
|
||||
return
|
||||
|
||||
try:
|
||||
stt_payload = {
|
||||
"text": text,
|
||||
"text": cleaned,
|
||||
"sender": "stt",
|
||||
}
|
||||
if audio_request_id:
|
||||
stt_payload["audioRequestId"] = audio_request_id
|
||||
if location:
|
||||
stt_payload["location"] = location
|
||||
if project_id:
|
||||
stt_payload["projectId"] = project_id
|
||||
ok = await self._send_to_rvs({
|
||||
"type": "chat",
|
||||
"payload": stt_payload,
|
||||
@@ -2920,10 +3126,11 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
||||
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
core_text = self._build_core_text(cleaned, interrupted, location)
|
||||
await self.send_to_core(core_text,
|
||||
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
||||
client_msg_id=client_msg_id)
|
||||
client_msg_id=client_msg_id,
|
||||
project_id=project_id)
|
||||
|
||||
async def _stt_remote(self, audio_b64: str, mime_type: str) -> Optional[str]:
|
||||
"""Schickt Audio an die whisper-bridge und wartet auf stt_response.
|
||||
|
||||
+297
-2
@@ -305,6 +305,12 @@
|
||||
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Multi-Threading: Kontext-Strip ueber dem Chat. Jeder Kontext
|
||||
(Hauptchat + aktive Projekte) als kompakte Karte mit Status-Dot.
|
||||
Tap wechselt den Focus — Chat-Box filtert dann auf diesen Kontext. -->
|
||||
<div id="chat-context-strip" style="display:flex;gap:6px;overflow-x:auto;padding:6px 4px;margin-bottom:6px;border-bottom:1px solid #1E1E2E;">
|
||||
<!-- wird von renderContextStrip() befuellt -->
|
||||
</div>
|
||||
<div class="chat-box" id="chat-box"></div>
|
||||
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
|
||||
<span><span style="animation:pulse 1s infinite;">💭</span> <span id="thinking-text">ARIA denkt...</span></span>
|
||||
@@ -992,6 +998,41 @@
|
||||
|
||||
<!-- Alte Sessions-Sicherung entfernt — aria-core ist raus. -->
|
||||
|
||||
<!-- Projekte — Threads-im-Hauptchat-Konzept -->
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<h2 style="margin:0;">📁 Projekte</h2>
|
||||
<div>
|
||||
<button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
|
||||
<button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||
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:
|
||||
<span id="project-active-label" style="color:#34C759;font-weight:600;">(wird geladen...)</span>
|
||||
</div>
|
||||
<div id="project-list" class="card" style="padding:0;">
|
||||
<div style="padding:14px;color:#8888AA;font-size:12px;">Lade Projekte...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neues-Projekt Modal -->
|
||||
<div id="project-create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;align-items:center;justify-content:center;">
|
||||
<div style="background:#15151E;padding:20px;border-radius:8px;min-width:340px;max-width:90vw;">
|
||||
<h3 style="margin-top:0;color:#E0E0F0;">Neues Projekt</h3>
|
||||
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Name</label>
|
||||
<input type="text" id="project-create-name" placeholder="z.B. Frankreich-Urlaub"
|
||||
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:14px;margin-bottom:10px;">
|
||||
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Beschreibung (optional)</label>
|
||||
<textarea id="project-create-desc" placeholder="1 Satz worum's geht. Hilft beim Wiederfinden."
|
||||
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:13px;height:60px;resize:vertical;margin-bottom:14px;"></textarea>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="btn secondary" onclick="closeCreateProjectModal()" style="padding:6px 14px;font-size:12px;">Abbrechen</button>
|
||||
<button class="btn primary" onclick="submitCreateProject()" style="padding:6px 14px;font-size:12px;">Anlegen + aktivieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
@@ -1074,6 +1115,11 @@
|
||||
<option value="aria">Von ARIA (aria_*)</option>
|
||||
<option value="user">Vom Benutzer</option>
|
||||
</select>
|
||||
<select id="files-filter-project" onchange="renderFilesList()" style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
||||
<option value="__all__">Alle Projekte</option>
|
||||
<option value="">💬 Hauptchat</option>
|
||||
<!-- Project options werden dynamisch via loadFiles() befuellt -->
|
||||
</select>
|
||||
</div>
|
||||
<div id="files-info" style="margin-top:6px;font-size:10px;color:#8888AA;"></div>
|
||||
</div>
|
||||
@@ -1277,6 +1323,94 @@
|
||||
|
||||
<script>
|
||||
const chatBox = document.getElementById('chat-box');
|
||||
|
||||
// ── Multi-Threading: Kontext-Focus fuer Diagnostic-Chat ─────
|
||||
// focusedContextId: leerer String = Hauptchat, sonst project_id.
|
||||
// Gefiltert werden Bubbles per data-project-id-Match (siehe addChat).
|
||||
// Send-Input uebergibt die Focus-ID ans Brain (via bridge → /chat).
|
||||
let focusedContextId = localStorage.getItem('diag_focused_context_id') || '';
|
||||
let diagQueueStatus = {};
|
||||
let diagProjectsCache = [];
|
||||
|
||||
function updateChatVisibilityByFocus() {
|
||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||
if (!box) continue;
|
||||
for (const el of box.querySelectorAll('.chat-msg')) {
|
||||
const pid = el.dataset.projectId || '';
|
||||
el.style.display = (pid === focusedContextId) ? '' : 'none';
|
||||
}
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function switchDiagFocus(id) {
|
||||
focusedContextId = id || '';
|
||||
localStorage.setItem('diag_focused_context_id', focusedContextId);
|
||||
updateChatVisibilityByFocus();
|
||||
renderContextStrip();
|
||||
}
|
||||
|
||||
function renderContextStrip() {
|
||||
const strip = document.getElementById('chat-context-strip');
|
||||
if (!strip) return;
|
||||
const chip = (id, name, isFocus, dotColor, subline) => {
|
||||
const bg = isFocus ? 'rgba(52,199,89,0.15)' : '#1E1E2E';
|
||||
const border = isFocus ? '#34C759' : '#2A2A3E';
|
||||
return `<div onclick="switchDiagFocus('${id}')" style="cursor:pointer;flex:0 0 auto;padding:6px 10px;background:${bg};border:1px solid ${border};border-radius:6px;display:flex;align-items:center;gap:6px;min-width:120px;">
|
||||
<div style="width:8px;height:8px;border-radius:4px;background:${dotColor};"></div>
|
||||
<div style="display:flex;flex-direction:column;min-width:0;">
|
||||
<div style="color:${isFocus?'#34C759':'#E0E0F0'};font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;">${escapeHtml(name)}</div>
|
||||
<div style="color:#8888AA;font-size:10px;">${subline}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
const dotFor = (key) => {
|
||||
const s = diagQueueStatus[key];
|
||||
if (!s) return { color: '#555570', label: '' };
|
||||
if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' };
|
||||
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
|
||||
return { color: '#34C759', label: 'idle' };
|
||||
};
|
||||
const cards = [];
|
||||
// Hauptchat
|
||||
const mainDot = dotFor('__main__');
|
||||
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle'));
|
||||
// Projekte — nur active/ended, sortiert nach letzter Aktivitaet
|
||||
for (const p of diagProjectsCache) {
|
||||
if (p.status === 'archived') continue;
|
||||
const d = dotFor(p.id);
|
||||
const sub = d.label || `${p.turn_count} Turns`;
|
||||
cards.push(chip(p.id, `📁 ${p.name}`, focusedContextId === p.id, d.color, sub));
|
||||
}
|
||||
strip.innerHTML = cards.join('');
|
||||
}
|
||||
|
||||
async function refreshDiagQueueStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/brain/projects/queue-status');
|
||||
const d = await r.json();
|
||||
diagQueueStatus = d?.contexts || {};
|
||||
renderContextStrip();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function refreshDiagProjectsCache() {
|
||||
try {
|
||||
const r = await fetch('/api/brain/projects/list?include_archived=false');
|
||||
const d = await r.json();
|
||||
diagProjectsCache = d?.projects || [];
|
||||
renderContextStrip();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Beim Load: Projekte laden + Polling starten
|
||||
setTimeout(() => {
|
||||
refreshDiagProjectsCache();
|
||||
refreshDiagQueueStatus();
|
||||
setInterval(refreshDiagQueueStatus, 2000);
|
||||
// Projekt-Liste alle 15s neu holen (neue Anlagen, umbenennen)
|
||||
setInterval(refreshDiagProjectsCache, 15000);
|
||||
}, 500);
|
||||
const pauseHint = document.getElementById('pause-hint');
|
||||
const btnScroll = document.getElementById('btn-scroll');
|
||||
let ws;
|
||||
@@ -1543,6 +1677,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) {
|
||||
@@ -1676,6 +1817,7 @@
|
||||
location: p.location,
|
||||
ttsText: p.ttsText,
|
||||
backupTs: p.backupTs,
|
||||
projectId: p.projectId || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1864,8 +2006,10 @@
|
||||
if (!text && diagPendingFiles.length === 0) return;
|
||||
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
||||
if (text) {
|
||||
addChat('sent', text, 'via RVS');
|
||||
send({ action: 'test_rvs', text });
|
||||
// Multi-Threading: mit fokussierter Kontext-ID senden.
|
||||
// Bridge routet an /chat body.project_id — Brain queued per Kontext.
|
||||
addChat('sent', text, 'via RVS', { projectId: focusedContextId });
|
||||
send({ action: 'test_rvs', text, projectId: focusedContextId });
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
@@ -2131,12 +2275,18 @@
|
||||
// Thinking-Indikator ausblenden bei neuer Nachricht
|
||||
updateThinkingIndicator({ activity: 'idle' });
|
||||
|
||||
// Projekt-Tag fuer Focus-Filter (Multi-Threading, 06/2026)
|
||||
const projectId = (options && options.projectId) || '';
|
||||
const hiddenByFocus = (typeof focusedContextId === 'string' && projectId !== focusedContextId);
|
||||
|
||||
// In beide Chat-Boxen schreiben (normal + Vollbild)
|
||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||
if (!box) continue;
|
||||
const el = document.createElement('div');
|
||||
el.className = `chat-msg ${type}`;
|
||||
if (backupTs) el.dataset.ts = String(backupTs);
|
||||
el.dataset.projectId = projectId;
|
||||
if (hiddenByFocus) el.style.display = 'none';
|
||||
el.innerHTML = html;
|
||||
box.appendChild(el);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
@@ -2694,6 +2844,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(`
|
||||
<div onclick="switchProject('')" style="cursor:pointer;padding:12px 14px;border-bottom:1px solid #1E1E2E;${!activeId ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
|
||||
<div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div>
|
||||
<div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
|
||||
</div>`);
|
||||
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(`
|
||||
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
|
||||
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
|
||||
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
|
||||
📁 ${escapeHtml(p.name)}
|
||||
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
|
||||
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
|
||||
</div>
|
||||
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
|
||||
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden">⏹</button>` : ''}
|
||||
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
if (projects.length === 0) {
|
||||
rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>');
|
||||
}
|
||||
listEl.innerHTML = rows.join('');
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 +3711,7 @@
|
||||
loadBrainMemoryList();
|
||||
refreshImportFiles();
|
||||
loadMetrics();
|
||||
loadProjects();
|
||||
} else if (tab === 'files') {
|
||||
loadFiles();
|
||||
} else if (tab === 'skills') {
|
||||
@@ -4055,6 +4317,37 @@
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler');
|
||||
filesCache = d.files || [];
|
||||
// Projekt-Filter-Optionen aktualisieren — Liste aller bekannten projectIds
|
||||
// aus den Dateien + Namen via brain api.
|
||||
const pidsInFiles = new Set(filesCache.map(f => f.projectId).filter(Boolean));
|
||||
try {
|
||||
const pr = await fetch('/api/brain/projects/list?include_archived=true');
|
||||
const pdata = await pr.json();
|
||||
const projects = pdata?.projects || [];
|
||||
const sel = document.getElementById('files-filter-project');
|
||||
if (sel) {
|
||||
const current = sel.value;
|
||||
// Bestehende Options ab Index 2 (nach __all__ und Hauptchat) entfernen
|
||||
while (sel.options.length > 2) sel.remove(2);
|
||||
for (const p of projects) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `📁 ${p.name}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// Auch IDs aus Files die nicht in projects sind (gelöschte Projekte)
|
||||
const knownIds = new Set(projects.map(p => p.id));
|
||||
for (const pid of pidsInFiles) {
|
||||
if (!knownIds.has(pid)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = pid;
|
||||
opt.textContent = `📁 ${pid} (gelöscht?)`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
sel.value = current || '__all__';
|
||||
}
|
||||
} catch {}
|
||||
// Selection bereinigen — nicht mehr existierende Pfade raus
|
||||
const existing = new Set(filesCache.map(f => f.path));
|
||||
for (const p of [...filesSelected]) if (!existing.has(p)) filesSelected.delete(p);
|
||||
@@ -4067,9 +4360,11 @@
|
||||
function getVisibleFiles() {
|
||||
const q = (document.getElementById('files-search').value || '').toLowerCase();
|
||||
const filter = document.getElementById('files-filter').value;
|
||||
const pidFilter = document.getElementById('files-filter-project')?.value || '__all__';
|
||||
let files = filesCache.slice();
|
||||
if (filter === 'aria') files = files.filter(f => f.fromAria);
|
||||
else if (filter === 'user') files = files.filter(f => !f.fromAria);
|
||||
if (pidFilter !== '__all__') files = files.filter(f => (f.projectId || '') === pidFilter);
|
||||
if (q) files = files.filter(f => f.name.toLowerCase().includes(q));
|
||||
return files;
|
||||
}
|
||||
|
||||
+64
-3
@@ -297,6 +297,32 @@ function writeRuntimeConfig(patch) {
|
||||
}
|
||||
|
||||
// Atomic write: temp-file + rename, laute Logs bei Fehler.
|
||||
// ── File-Project-Manifest ───────────────────────────────────────────
|
||||
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
|
||||
// Wird vom files-list-Endpoint + files-set-project gepflegt.
|
||||
const FILE_PROJECTS_FILE = "/shared/config/file_projects.json";
|
||||
|
||||
function loadFileProjects() {
|
||||
try {
|
||||
if (!fs.existsSync(FILE_PROJECTS_FILE)) return {};
|
||||
const data = JSON.parse(fs.readFileSync(FILE_PROJECTS_FILE, "utf-8"));
|
||||
return (data && typeof data === "object") ? data : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveFileProjects(manifest) {
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
const tmp = FILE_PROJECTS_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
|
||||
fs.renameSync(tmp, FILE_PROJECTS_FILE);
|
||||
} catch (err) {
|
||||
log("warn", "files", `file-projects-Manifest schreiben fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function persistActiveSession(key) {
|
||||
try {
|
||||
const tmp = SESSION_KEY_FILE + ".tmp";
|
||||
@@ -975,18 +1001,26 @@ function sendToRVS_raw(msgObj) {
|
||||
freshWs.on("error", () => {});
|
||||
}
|
||||
|
||||
function sendToRVS(text, isTrace) {
|
||||
function sendToRVS(text, isTrace, projectId) {
|
||||
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
||||
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
||||
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
||||
// Diagnostic die Bubble live spiegeln koennen.
|
||||
//
|
||||
// projectId (Multi-Threading 06/2026): optional — leerer/undefined String
|
||||
// = Hauptchat, sonst project_id. Bridge liest payload.projectId und routet
|
||||
// an /chat body.project_id — Brain queued per Kontext.
|
||||
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
||||
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
||||
return false;
|
||||
}
|
||||
sendToRVS_raw({
|
||||
type: "chat",
|
||||
payload: { text, sender: "diagnostic" },
|
||||
payload: {
|
||||
text,
|
||||
sender: "diagnostic",
|
||||
projectId: projectId || "",
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return true;
|
||||
@@ -1598,6 +1632,7 @@ const server = http.createServer((req, res) => {
|
||||
const dir = "/shared/uploads";
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(dir); } catch { entries = []; }
|
||||
const manifest = loadFileProjects();
|
||||
const files = entries
|
||||
.map(name => {
|
||||
try {
|
||||
@@ -1610,6 +1645,7 @@ const server = http.createServer((req, res) => {
|
||||
size: st.size,
|
||||
mtime: Math.floor(st.mtimeMs),
|
||||
fromAria: name.startsWith("aria_"),
|
||||
projectId: manifest[full] || '',
|
||||
};
|
||||
} catch { return null; }
|
||||
})
|
||||
@@ -1622,6 +1658,31 @@ const server = http.createServer((req, res) => {
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
return;
|
||||
} else if (req.url === "/api/files-set-project" && req.method === "POST") {
|
||||
// Body: { path, projectId } — projectId leer = Hauptchat (= Eintrag entfernen)
|
||||
let body = "";
|
||||
req.on("data", c => { body += c; if (body.length > 8192) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body || "{}");
|
||||
const fpath = String(data.path || "");
|
||||
const pid = String(data.projectId || "");
|
||||
if (!fpath.startsWith("/shared/uploads/") || !fs.existsSync(fpath)) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: false, error: "Datei nicht gefunden" }));
|
||||
}
|
||||
const manifest = loadFileProjects();
|
||||
if (pid) manifest[fpath] = pid;
|
||||
else delete manifest[fpath];
|
||||
saveFileProjects(manifest);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, path: fpath, projectId: pid }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if ((req.url.startsWith("/api/files-download?") || req.url.startsWith("/api/files-view?")) && req.method === "GET") {
|
||||
// /api/files-download → mit Content-Disposition:attachment (Browser downloaded)
|
||||
// /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab)
|
||||
@@ -2232,7 +2293,7 @@ wss.on("connection", (ws) => {
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||
} else if (msg.action === "test_rvs") {
|
||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
|
||||
} else if (msg.action === "reconnect_gateway") {
|
||||
connectGateway();
|
||||
} else if (msg.action === "reconnect_rvs") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user