Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1baa1a7a08 | |||
| fc0f91d1e6 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10903
|
versionCode 10904
|
||||||
versionName "0.1.9.3"
|
versionName "0.1.9.4"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.9.3",
|
"version": "0.1.9.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Projekt-Übersicht + Switcher.
|
||||||
|
*
|
||||||
|
* Modal-Komponente die:
|
||||||
|
* - Den aktuellen Projekt-Status zeigt (Hauptchat oder konkretes Projekt)
|
||||||
|
* - Die Projekt-Liste rendert (sortiert nach letzter Aktivität)
|
||||||
|
* - Per Tap zwischen Projekten wechseln lässt
|
||||||
|
* - Neue Projekte anlegen kann
|
||||||
|
* - Bestehende editieren/beenden/archivieren
|
||||||
|
*
|
||||||
|
* Eingesetzt von ChatScreen (über den Projekt-Indicator) und von
|
||||||
|
* SettingsScreen.tsx in der Section 'projects'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import brainApi, { Project } from '../services/brainApi';
|
||||||
|
import rvs from '../services/rvs';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Optional — wenn als Modal genutzt, sonst inline */
|
||||||
|
visible?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
/** Wird gerufen wenn sich das aktive Projekt aendert — ChatScreen
|
||||||
|
* refresht dann seinen Banner-State. */
|
||||||
|
onActiveChanged?: (project: Project | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtRel(unixSec: number): string {
|
||||||
|
if (!unixSec) return '?';
|
||||||
|
const diff = (Date.now() / 1000) - unixSec;
|
||||||
|
if (diff < 60) return 'gerade eben';
|
||||||
|
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
|
||||||
|
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
|
||||||
|
if (diff < 86400 * 14) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
||||||
|
return new Date(unixSec * 1000).toLocaleDateString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged }) => {
|
||||||
|
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('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr(null);
|
||||||
|
brainApi.getProjectStatus()
|
||||||
|
.then(status => {
|
||||||
|
setProjects(status.projects || []);
|
||||||
|
setActiveId(status.active_id || '');
|
||||||
|
onActiveChanged?.(status.active);
|
||||||
|
})
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [onActiveChanged]);
|
||||||
|
|
||||||
|
useEffect(() => { if (visible) load(); }, [visible, load]);
|
||||||
|
|
||||||
|
// Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const unsub = rvs.onStateChange((state) => { if (state === 'connected') load(); });
|
||||||
|
return () => unsub();
|
||||||
|
}, [visible, load]);
|
||||||
|
|
||||||
|
const switchTo = useCallback((id: string) => {
|
||||||
|
brainApi.switchProject(id)
|
||||||
|
.then(status => {
|
||||||
|
setActiveId(status.active_id || '');
|
||||||
|
onActiveChanged?.(status.active);
|
||||||
|
})
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
}, [onActiveChanged]);
|
||||||
|
|
||||||
|
const createProject = useCallback(() => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
brainApi.createProject({ name, description: newDesc.trim() })
|
||||||
|
.then(() => {
|
||||||
|
setNewName(''); setNewDesc(''); setNewOpen(false);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch(e => Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e)));
|
||||||
|
}, [newName, newDesc, load]);
|
||||||
|
|
||||||
|
const openEdit = useCallback((p: Project) => {
|
||||||
|
setEditing(p);
|
||||||
|
setEditName(p.name);
|
||||||
|
setEditDesc(p.description || '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveEdit = useCallback(() => {
|
||||||
|
if (!editing) return;
|
||||||
|
const patch: Partial<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;
|
||||||
|
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 }}>
|
||||||
|
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
||||||
|
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
||||||
|
{isActive && <Text style={s.activeBadge}>✓ AKTIV</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)}
|
||||||
|
</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) */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => switchTo('')}
|
||||||
|
style={[s.row, !activeId && s.rowActive]}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
|
||||||
|
{!activeId && <Text style={s.activeBadge}>✓ AKTIV</Text>}
|
||||||
|
</View>
|
||||||
|
<Text style={s.rowMeta}>Standard-Verlauf, keine Projekt-Zuordnung</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 rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
|
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 phoneCallService from '../services/phoneCall';
|
||||||
import { playWakeReadySound } from '../services/wakeReadySound';
|
import { playWakeReadySound } from '../services/wakeReadySound';
|
||||||
import {
|
import {
|
||||||
@@ -280,6 +282,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
const [showJumpDown, setShowJumpDown] = useState(false);
|
const [showJumpDown, setShowJumpDown] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchVisible, setSearchVisible] = useState(false);
|
const [searchVisible, setSearchVisible] = useState(false);
|
||||||
|
const [projectsVisible, setProjectsVisible] = useState(false);
|
||||||
|
const [activeProject, setActiveProject] = useState<BrainProject | null>(null);
|
||||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||||
@@ -457,6 +461,19 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
||||||
// sofort greift, ohne Context- oder Event-System)
|
// sofort greift, ohne Context- oder Event-System)
|
||||||
|
// Aktives Projekt initial laden + bei RVS-Reconnect refreshen.
|
||||||
|
// Wird zusaetzlich nach jedem chat-Response refreshed (siehe handleAriaMessage).
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProject = () => {
|
||||||
|
brainApi.getProjectStatus()
|
||||||
|
.then(s => setActiveProject(s.active || null))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
loadProject();
|
||||||
|
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadProject(); });
|
||||||
|
return () => unsub();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
||||||
@@ -795,6 +812,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// project_changed: ARIA hat in einem Tool-Call ein Projekt erstellt /
|
||||||
|
// betreten / verlassen / beendet. Banner refreshen.
|
||||||
|
if (message.type === 'project_changed') {
|
||||||
|
brainApi.getProjectStatus()
|
||||||
|
.then(s => setActiveProject(s.active || null))
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === 'skill_created') {
|
if (message.type === 'skill_created') {
|
||||||
const p = (message.payload || {}) as any;
|
const p = (message.payload || {}) as any;
|
||||||
const skillMsg: ChatMessage = {
|
const skillMsg: ChatMessage = {
|
||||||
@@ -2457,6 +2483,32 @@ const ChatScreen: React.FC = () => {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setProjectsVisible(true)}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 12, paddingVertical: 6,
|
||||||
|
backgroundColor: activeProject ? 'rgba(52,199,89,0.10)' : 'transparent',
|
||||||
|
borderBottomWidth: activeProject ? 2 : 1,
|
||||||
|
borderColor: activeProject ? '#34C759' : '#1E1E2E',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
|
||||||
|
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 11, color: '#555570' }}>
|
||||||
|
{activeProject ? 'wechseln ›' : 'Projekte ›'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Projekt-Modal */}
|
||||||
|
<ProjectsBrowser
|
||||||
|
visible={projectsVisible}
|
||||||
|
onClose={() => setProjectsVisible(false)}
|
||||||
|
onActiveChanged={(p) => setActiveProject(p)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Suchleiste mit Treffer-Navigation */}
|
{/* Suchleiste mit Treffer-Navigation */}
|
||||||
{searchVisible && (
|
{searchVisible && (
|
||||||
<View style={styles.searchBar}>
|
<View style={styles.searchBar}>
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import TriggerBrowser from '../components/TriggerBrowser';
|
|||||||
import SkillBrowser from '../components/SkillBrowser';
|
import SkillBrowser from '../components/SkillBrowser';
|
||||||
import OAuthBrowser from '../components/OAuthBrowser';
|
import OAuthBrowser from '../components/OAuthBrowser';
|
||||||
import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
|
import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
|
||||||
|
import ProjectsBrowser from '../components/ProjectsBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
@@ -142,6 +143,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
{ 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: '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: '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: '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' },
|
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
|
||||||
@@ -1280,7 +1282,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||||
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||||
// scrolling laesst sonst nur in eine Richtung scrollen.
|
// 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 && (
|
{currentSection === null && (
|
||||||
@@ -2189,6 +2191,18 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</View>
|
</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 === */}
|
{/* === Gedaechtnis === */}
|
||||||
{currentSection === 'memory' && (<>
|
{currentSection === 'memory' && (<>
|
||||||
<Text style={styles.sectionTitle}>Gedächtnis</Text>
|
<Text style={styles.sectionTitle}>Gedächtnis</Text>
|
||||||
|
|||||||
@@ -151,6 +151,24 @@ export interface OAuthAppConfig {
|
|||||||
token_url?: string | null;
|
token_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Projekt — Stefans Threading-Konzept im Hauptchat. */
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: 'active' | 'ended' | 'archived';
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
last_activity_at: number;
|
||||||
|
turn_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectStatus {
|
||||||
|
active_id: string;
|
||||||
|
active: Project | null;
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -521,6 +539,57 @@ export const brainApi = {
|
|||||||
timeoutMs: 15000,
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default brainApi;
|
export default brainApi;
|
||||||
|
|||||||
+244
-8
@@ -32,6 +32,7 @@ import skills as skills_mod
|
|||||||
import triggers as triggers_mod
|
import triggers as triggers_mod
|
||||||
import watcher as watcher_mod
|
import watcher as watcher_mod
|
||||||
import oauth as oauth_mod
|
import oauth as oauth_mod
|
||||||
|
import projects as projects_mod
|
||||||
|
|
||||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||||
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
||||||
@@ -808,6 +809,104 @@ META_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
# ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "project_create",
|
||||||
|
"description": (
|
||||||
|
"Legt ein neues Projekt an und macht es ZUR AKTIVEN Bühne. "
|
||||||
|
"Nutze das wenn Stefan sagt 'lass uns ein Projekt für X anlegen' "
|
||||||
|
"oder ein Thema klar als zusammenhängend bezeichnet. NICHT für "
|
||||||
|
"Ad-hoc-Fragen — Projekte sind für wiederkehrende, mehrere Tage "
|
||||||
|
"spannende Themen (Spotify-Setup, Renovierung, Reise-Planung)."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Kurzer Name, wie ein Buchtitel ('Aria-Wakeword', 'Frankreich-Urlaub')."},
|
||||||
|
"description": {"type": "string", "description": "1-Satz worum's geht. Hilft beim Wiedererkennen."},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "project_enter",
|
||||||
|
"description": (
|
||||||
|
"Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — "
|
||||||
|
"'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt "
|
||||||
|
"tagged jeder neue Turn die project_id. Bei sehr alten Projekten: "
|
||||||
|
"vorher project_summary aufrufen damit Du Stefan abholst."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Projekt-Name oder Teil davon."},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "project_exit",
|
||||||
|
"description": (
|
||||||
|
"Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze "
|
||||||
|
"wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä."
|
||||||
|
),
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "project_list",
|
||||||
|
"description": "Listet alle Projekte mit Status und letzter Aktivität. Bevor Du ein neues anlegst: hier prüfen ob's schon eins gibt.",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "project_summary",
|
||||||
|
"description": (
|
||||||
|
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
|
||||||
|
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
|
||||||
|
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
|
||||||
|
"halluzinierst Du Inhalte die nicht da sind."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "project_end",
|
||||||
|
"description": (
|
||||||
|
"Markiert ein Projekt als beendet — bleibt in der Liste sichtbar "
|
||||||
|
"(z.B. archiviert/grau), kann aber nicht mehr neu betreten werden "
|
||||||
|
"außer mit explizitem project_enter. Nutze wenn Stefan sagt 'Projekt "
|
||||||
|
"abgeschlossen' o.ä."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Projekt-Name."},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -960,17 +1059,28 @@ class Agent:
|
|||||||
# Events vom letzten Turn weglassen
|
# Events vom letzten Turn weglassen
|
||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
|
|
||||||
|
# Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der
|
||||||
|
# neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt.
|
||||||
|
active_project_id = projects_mod.get_active()
|
||||||
|
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
|
||||||
|
|
||||||
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
||||||
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
||||||
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
||||||
fast_reply = self._try_skill_fast_path(user_message)
|
fast_reply = self._try_skill_fast_path(user_message)
|
||||||
if fast_reply is not None:
|
if fast_reply is not None:
|
||||||
self.conversation.add("user", user_message, source=source)
|
self.conversation.add("user", user_message, source=source,
|
||||||
self.conversation.add("assistant", fast_reply)
|
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
|
return fast_reply
|
||||||
|
|
||||||
# 1. User-Turn an die Konversation
|
# 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)
|
# 2. Hot Memory (alle pinned Punkte)
|
||||||
hot = self.store.list_pinned()
|
hot = self.store.list_pinned()
|
||||||
@@ -1017,13 +1127,38 @@ class Agent:
|
|||||||
oauth_callback_host=oauth_host,
|
oauth_callback_host=oauth_host,
|
||||||
oauth_callback_port=oauth_port,
|
oauth_callback_port=oauth_port,
|
||||||
oauth_callback_tls=oauth_tls)
|
oauth_callback_tls=oauth_tls)
|
||||||
|
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
|
||||||
|
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
|
||||||
|
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
|
||||||
|
if active_project:
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\n## AKTUELLES PROJEKT\n"
|
||||||
|
f"Stefan befindet sich gerade IN dem Projekt '{active_project['name']}' "
|
||||||
|
f"(id={active_project['id']}). Beschreibung: "
|
||||||
|
f"{active_project.get('description', '(keine)')}. "
|
||||||
|
f"Alle Antworten in diesem Turn gelten fuer dieses Projekt. "
|
||||||
|
f"Wenn er rauswill, ruf project_exit auf."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
project_count = len(projects_mod.list_projects())
|
||||||
|
if project_count > 0:
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\n## PROJEKTE\n"
|
||||||
|
f"Hauptthread aktiv. {project_count} Projekte verfuegbar — wenn "
|
||||||
|
f"Stefan sagt 'in Projekt X' oder 'lass uns das Spotify-Thema "
|
||||||
|
f"weiterfuehren': project_enter aufrufen."
|
||||||
|
)
|
||||||
messages = [ProxyMessage(role="system", content=system_prompt)]
|
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))
|
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(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
|
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
|
||||||
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
|
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
|
||||||
@@ -1082,13 +1217,19 @@ class Agent:
|
|||||||
err_text = f"[Fehler: {exc}]"
|
err_text = f"[Fehler: {exc}]"
|
||||||
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
|
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
|
||||||
try:
|
try:
|
||||||
self.conversation.add("assistant", err_text)
|
# Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops
|
||||||
|
# geaendert haben (project_enter/exit als Tool-Call).
|
||||||
|
self.conversation.add("assistant", err_text,
|
||||||
|
project_id=projects_mod.get_active())
|
||||||
except Exception as add_exc:
|
except Exception as add_exc:
|
||||||
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
|
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# 7. Assistant-Turn (final reply) in die Conversation
|
# 7. Assistant-Turn (final reply) in die Conversation
|
||||||
self.conversation.add("assistant", final_reply)
|
# NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der
|
||||||
|
# Final-Reply schon im neuen Projekt-Kontext.
|
||||||
|
self.conversation.add("assistant", final_reply,
|
||||||
|
project_id=projects_mod.get_active())
|
||||||
return final_reply
|
return final_reply
|
||||||
|
|
||||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||||
@@ -1648,6 +1789,101 @@ class Agent:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("memory_save fehlgeschlagen")
|
logger.exception("memory_save fehlgeschlagen")
|
||||||
return f"FEHLER beim Speichern: {e}"
|
return f"FEHLER beim Speichern: {e}"
|
||||||
|
# ── Projekte ────────────────────────────────────────
|
||||||
|
if name == "project_create":
|
||||||
|
pname = (arguments.get("name") or "").strip()
|
||||||
|
desc = (arguments.get("description") or "").strip()
|
||||||
|
if not pname:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
try:
|
||||||
|
p = projects_mod.create_project(pname, desc)
|
||||||
|
except ValueError as e:
|
||||||
|
return f"FEHLER: {e}"
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "project_changed",
|
||||||
|
"project": p,
|
||||||
|
"action": "created",
|
||||||
|
})
|
||||||
|
return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst."
|
||||||
|
if name == "project_enter":
|
||||||
|
pname = (arguments.get("name") or "").strip()
|
||||||
|
if not pname:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
p = projects_mod.find_project(pname)
|
||||||
|
if not p:
|
||||||
|
return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
|
||||||
|
projects_mod.set_active(p["id"])
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "project_changed",
|
||||||
|
"project": p,
|
||||||
|
"action": "entered",
|
||||||
|
})
|
||||||
|
turn_count = p.get("turn_count", 0)
|
||||||
|
hint = ""
|
||||||
|
if turn_count > 0:
|
||||||
|
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
|
||||||
|
return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}"
|
||||||
|
if name == "project_exit":
|
||||||
|
active_id = projects_mod.get_active()
|
||||||
|
if not active_id:
|
||||||
|
return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread."
|
||||||
|
p = projects_mod.get_project(active_id)
|
||||||
|
projects_mod.set_active("")
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "project_changed",
|
||||||
|
"project": p,
|
||||||
|
"action": "exited",
|
||||||
|
})
|
||||||
|
return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread."
|
||||||
|
if name == "project_list":
|
||||||
|
items = projects_mod.list_projects()
|
||||||
|
if not items:
|
||||||
|
return "(keine Projekte angelegt)"
|
||||||
|
active_id = projects_mod.get_active()
|
||||||
|
lines = []
|
||||||
|
for p in items:
|
||||||
|
marker = " ← AKTIV" if p["id"] == active_id else ""
|
||||||
|
status_lbl = p.get("status", "active")
|
||||||
|
lines.append(
|
||||||
|
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
|
||||||
|
f"status={status_lbl}){marker}"
|
||||||
|
)
|
||||||
|
return "Projekte:\n" + "\n".join(lines)
|
||||||
|
if name == "project_summary":
|
||||||
|
pname = (arguments.get("name") or "").strip()
|
||||||
|
if not pname:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
p = projects_mod.find_project(pname)
|
||||||
|
if not p:
|
||||||
|
return f"Kein Projekt '{pname}' gefunden."
|
||||||
|
# Letzte ~10 Turns des Projekts aus dem Conversation-Log
|
||||||
|
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
|
||||||
|
if not turns:
|
||||||
|
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
|
||||||
|
f"aktuellen Conversation-Window stehen noch keine Turns. "
|
||||||
|
f"Beschreibung: {p.get('description', '(keine)')}")
|
||||||
|
tail = turns[-12:]
|
||||||
|
summary_lines = []
|
||||||
|
for t in tail:
|
||||||
|
prefix = "Stefan" if t.role == "user" else "Du"
|
||||||
|
summary_lines.append(f"{prefix}: {t.content[:280]}")
|
||||||
|
preamble = (f"Projekt '{p['name']}' — {p.get('description', '(keine Beschreibung)')}.\n"
|
||||||
|
f"Letzte {len(tail)} Turns:\n")
|
||||||
|
return preamble + "\n".join(summary_lines)
|
||||||
|
if name == "project_end":
|
||||||
|
pname = (arguments.get("name") or "").strip()
|
||||||
|
if not pname:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
p = projects_mod.find_project(pname)
|
||||||
|
if not p:
|
||||||
|
return f"Kein Projekt '{pname}' gefunden."
|
||||||
|
projects_mod.end_project(p["id"])
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "project_changed",
|
||||||
|
"project": projects_mod.get_project(p["id"]),
|
||||||
|
"action": "ended",
|
||||||
|
})
|
||||||
|
return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
|
||||||
return f"Unbekanntes Tool: {name}"
|
return f"Unbekanntes Tool: {name}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||||
|
|||||||
+38
-10
@@ -32,6 +32,7 @@ class Turn:
|
|||||||
content: str
|
content: str
|
||||||
ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
source: str = "" # "app" / "diagnostic" / "stt" — optional
|
source: str = "" # "app" / "diagnostic" / "stt" — optional
|
||||||
|
project_id: str = "" # leer = Hauptthread; sonst projects.py-ID
|
||||||
|
|
||||||
|
|
||||||
class Conversation:
|
class Conversation:
|
||||||
@@ -73,7 +74,8 @@ class Conversation:
|
|||||||
if role in ("user", "assistant") and isinstance(content, str):
|
if role in ("user", "assistant") and isinstance(content, str):
|
||||||
loaded.append(Turn(role=role, content=content,
|
loaded.append(Turn(role=role, content=content,
|
||||||
ts=obj.get("ts", ""),
|
ts=obj.get("ts", ""),
|
||||||
source=obj.get("source", "")))
|
source=obj.get("source", ""),
|
||||||
|
project_id=obj.get("project_id", "")))
|
||||||
self.turns = loaded
|
self.turns = loaded
|
||||||
logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE)
|
logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE)
|
||||||
|
|
||||||
@@ -85,17 +87,40 @@ class Conversation:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Konversation persist fehlgeschlagen: %s", exc)
|
logger.warning("Konversation persist fehlgeschlagen: %s", exc)
|
||||||
|
|
||||||
def add(self, role: str, content: str, source: str = "") -> Turn:
|
def add(self, role: str, content: str, source: str = "",
|
||||||
t = Turn(role=role, content=content, source=source)
|
project_id: str = "") -> Turn:
|
||||||
|
t = Turn(role=role, content=content, source=source, project_id=project_id)
|
||||||
self.turns.append(t)
|
self.turns.append(t)
|
||||||
self._append_to_file({
|
record = {
|
||||||
"ts": t.ts, "role": t.role, "content": t.content, "source": t.source,
|
"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
|
return t
|
||||||
|
|
||||||
def window(self) -> List[Turn]:
|
def window(self, project_id: Optional[str] = None) -> List[Turn]:
|
||||||
"""Die letzten max_window Turns — gehen in den LLM-Prompt."""
|
"""Die letzten max_window Turns — gehen in den LLM-Prompt.
|
||||||
return self.turns[-self.max_window:]
|
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:
|
def needs_distill(self) -> bool:
|
||||||
return len(self.turns) > self.distill_threshold
|
return len(self.turns) > self.distill_threshold
|
||||||
@@ -131,10 +156,13 @@ class Conversation:
|
|||||||
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
|
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
|
||||||
with tmp.open("w", encoding="utf-8") as f:
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
for t in self.turns:
|
for t in self.turns:
|
||||||
f.write(json.dumps({
|
rec = {
|
||||||
"ts": t.ts, "role": t.role,
|
"ts": t.ts, "role": t.role,
|
||||||
"content": t.content, "source": t.source,
|
"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)
|
tmp.replace(CONVERSATION_FILE)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Konversation rewrite fehlgeschlagen: %s", exc)
|
logger.warning("Konversation rewrite fehlgeschlagen: %s", exc)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import watcher as watcher_mod
|
|||||||
import background as background_mod
|
import background as background_mod
|
||||||
import oauth as oauth_mod
|
import oauth as oauth_mod
|
||||||
import seed_rules as seed_rules_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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
logger = logging.getLogger("aria-brain")
|
logger = logging.getLogger("aria-brain")
|
||||||
@@ -639,6 +640,76 @@ def chat(body: ChatIn, background: BackgroundTasks):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Projekte ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/projects/status")
|
||||||
|
def projects_status():
|
||||||
|
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
|
||||||
|
return projects_mod.status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/list")
|
||||||
|
def projects_list(include_archived: bool = False):
|
||||||
|
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreateBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/create")
|
||||||
|
def projects_create(body: ProjectCreateBody):
|
||||||
|
try:
|
||||||
|
p = projects_mod.create_project(body.name, body.description)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSwitchBody(BaseModel):
|
||||||
|
project_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/switch")
|
||||||
|
def projects_switch(body: ProjectSwitchBody):
|
||||||
|
"""Aktive Projekt-ID setzen. Leerer String → Hauptthread."""
|
||||||
|
if body.project_id:
|
||||||
|
p = projects_mod.get_project(body.project_id)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden")
|
||||||
|
projects_mod.set_active(body.project_id)
|
||||||
|
return projects_mod.status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/end")
|
||||||
|
def projects_end(project_id: str):
|
||||||
|
if not projects_mod.end_project(project_id):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||||
|
return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/{project_id}/archive")
|
||||||
|
def projects_archive(project_id: str):
|
||||||
|
if not projects_mod.archive_project(project_id):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||||
|
return {"id": project_id, "status": "archived"}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdateBody(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/projects/{project_id}")
|
||||||
|
def projects_update(project_id: str, body: ProjectUpdateBody):
|
||||||
|
patch = body.dict(exclude_unset=True)
|
||||||
|
p = projects_mod.update_project(project_id, patch)
|
||||||
|
if p is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
@app.get("/conversation/stats")
|
@app.get("/conversation/stats")
|
||||||
def conversation_stats():
|
def conversation_stats():
|
||||||
return conversation().stats()
|
return 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),
|
||||||
|
}
|
||||||
@@ -1564,6 +1564,20 @@ class ARIABridge:
|
|||||||
logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)",
|
logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)",
|
||||||
event.get("memory", {}).get("title"),
|
event.get("memory", {}).get("title"),
|
||||||
event.get("memory", {}).get("type"))
|
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:
|
# _process_core_response uebernimmt alles weitere:
|
||||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||||
|
|||||||
@@ -992,6 +992,41 @@
|
|||||||
|
|
||||||
<!-- Alte Sessions-Sicherung entfernt — aria-core ist raus. -->
|
<!-- 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 class="settings-section">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||||
@@ -1543,6 +1578,13 @@
|
|||||||
return;
|
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') {
|
if (msg.type === 'voice_id_delete_response') {
|
||||||
const p = msg.payload || msg;
|
const p = msg.payload || msg;
|
||||||
if (p.removed) {
|
if (p.removed) {
|
||||||
@@ -2694,6 +2736,117 @@
|
|||||||
send({ action: 'voice_id_delete' });
|
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) {
|
function deleteXttsVoice(name) {
|
||||||
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
|
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
|
||||||
send({ action: 'xtts_delete_voice', name });
|
send({ action: 'xtts_delete_voice', name });
|
||||||
@@ -3450,6 +3603,7 @@
|
|||||||
loadBrainMemoryList();
|
loadBrainMemoryList();
|
||||||
refreshImportFiles();
|
refreshImportFiles();
|
||||||
loadMetrics();
|
loadMetrics();
|
||||||
|
loadProjects();
|
||||||
} else if (tab === 'files') {
|
} else if (tab === 'files') {
|
||||||
loadFiles();
|
loadFiles();
|
||||||
} else if (tab === 'skills') {
|
} else if (tab === 'skills') {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"voice_id_status_request", "voice_id_status_response",
|
"voice_id_status_request", "voice_id_status_response",
|
||||||
"voice_id_enroll_request", "voice_id_enroll_response",
|
"voice_id_enroll_request", "voice_id_enroll_response",
|
||||||
"voice_id_delete_request", "voice_id_delete_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,
|
// File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
|
||||||
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
|
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
|
||||||
"file_version_list_request", "file_version_list_response",
|
"file_version_list_request", "file_version_list_response",
|
||||||
|
|||||||
Reference in New Issue
Block a user