feat(projects): Threads im Hauptchat verankert (Stefan-Konzept)
Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.
Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
(/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
_summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
{id}/end,{id}/archive, PATCH /{id}}.
Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.
App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.
Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
project_changed-Event-Broadcast.
Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 audioService from '../services/audio';
|
||||
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
|
||||
import ProjectsBrowser from '../components/ProjectsBrowser';
|
||||
import brainApi, { Project as BrainProject } from '../services/brainApi';
|
||||
import phoneCallService from '../services/phoneCall';
|
||||
import { playWakeReadySound } from '../services/wakeReadySound';
|
||||
import {
|
||||
@@ -280,6 +282,8 @@ const ChatScreen: React.FC = () => {
|
||||
const [showJumpDown, setShowJumpDown] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchVisible, setSearchVisible] = useState(false);
|
||||
const [projectsVisible, setProjectsVisible] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<BrainProject | null>(null);
|
||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||
@@ -457,6 +461,19 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
||||
// sofort greift, ohne Context- oder Event-System)
|
||||
// Aktives Projekt initial laden + bei RVS-Reconnect refreshen.
|
||||
// Wird zusaetzlich nach jedem chat-Response refreshed (siehe handleAriaMessage).
|
||||
useEffect(() => {
|
||||
const loadProject = () => {
|
||||
brainApi.getProjectStatus()
|
||||
.then(s => setActiveProject(s.active || null))
|
||||
.catch(() => {});
|
||||
};
|
||||
loadProject();
|
||||
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadProject(); });
|
||||
return () => unsub();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
||||
@@ -795,6 +812,15 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// project_changed: ARIA hat in einem Tool-Call ein Projekt erstellt /
|
||||
// betreten / verlassen / beendet. Banner refreshen.
|
||||
if (message.type === 'project_changed') {
|
||||
brainApi.getProjectStatus()
|
||||
.then(s => setActiveProject(s.active || null))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'skill_created') {
|
||||
const p = (message.payload || {}) as any;
|
||||
const skillMsg: ChatMessage = {
|
||||
@@ -2457,6 +2483,32 @@ const ChatScreen: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */}
|
||||
<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 */}
|
||||
{searchVisible && (
|
||||
<View style={styles.searchBar}>
|
||||
|
||||
@@ -92,6 +92,7 @@ import TriggerBrowser from '../components/TriggerBrowser';
|
||||
import SkillBrowser from '../components/SkillBrowser';
|
||||
import OAuthBrowser from '../components/OAuthBrowser';
|
||||
import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
|
||||
import ProjectsBrowser from '../components/ProjectsBrowser';
|
||||
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
@@ -142,6 +143,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||
{ id: 'projects', icon: '📁', label: 'Projekte', desc: 'Thread-Bündel im Hauptchat — verwalten, wechseln, beenden' },
|
||||
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
|
||||
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
|
||||
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
|
||||
@@ -1280,7 +1282,7 @@ const SettingsScreen: React.FC = () => {
|
||||
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth' && currentSection !== 'projects'}
|
||||
>
|
||||
|
||||
{currentSection === null && (
|
||||
@@ -2189,6 +2191,18 @@ const SettingsScreen: React.FC = () => {
|
||||
</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,24 @@ export interface OAuthAppConfig {
|
||||
token_url?: string | null;
|
||||
}
|
||||
|
||||
/** Projekt — Stefans Threading-Konzept im Hauptchat. */
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'active' | 'ended' | 'archived';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_activity_at: number;
|
||||
turn_count: number;
|
||||
}
|
||||
|
||||
export interface ProjectStatus {
|
||||
active_id: string;
|
||||
active: Project | null;
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||
export interface Skill {
|
||||
name: string;
|
||||
@@ -521,6 +539,57 @@ export const brainApi = {
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── Projekte ───────────────────────────────────────────────────
|
||||
|
||||
/** Kompletter Status: aktives Projekt + Liste. */
|
||||
getProjectStatus(): Promise<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;
|
||||
|
||||
Reference in New Issue
Block a user