Compare commits

...

4 Commits

Author SHA1 Message Date
duffyduck d430fa113e release: bump version to 0.1.9.5 2026-06-13 21:56:57 +02:00
duffyduck 1fb512c2fd fix+feat(projects): Spinner-Bug, Back-Button, kollabierbare Chat-Bloecke, File-Filter
Drei Stefan-Bugs aus dem ersten Deploy-Test plus die fehlenden Polish-
Features fuer die Projekt-Funktion.

Fixes:
- ProjectsBrowser-Spinner-Hang: useRef-Pattern statt useCallback([onActive
  Changed]) — Parent uebergibt inline-arrow-Callbacks, neue Identitaet
  jedes Render → useCallback recomputes → useEffect refeuert → infinite
  Spinner. Fix: Ref-Bridge fuer Callbacks, useCallback mit empty deps.
- ChatScreen Banner: zusaetzlicher × Hauptchat-Button rechts (sichtbar
  nur wenn Projekt aktiv) — ein Tap und zurueck zum Hauptthread, ohne
  Modal-Umweg.

Features:
- Brain ChatOut.project_id: aktive Projekt-ID NACH dem Turn (kann
  durch project_enter/exit-Tools waehrend Turn gewechselt sein). Bridge
  liest sie aus dem /chat-Response und haengt sie an jeden ARIA-Chat-
  Broadcast als payload.projectId.
- App: ChatMessage.projectId-Feld. User-Bubbles werden mit aktiver
  Projekt-ID getaggt vor dem Senden (auch im RVS-Payload). ARIA-Bubbles
  kriegen die ID vom Bridge.
- App: Chat-Verlauf rendert aufeinanderfolgende Project-Messages als
  einklappbaren Block mit Header (▶/▼ + Projekt-Name + Count). Auto-
  Collapse beim Projekt-Wechsel (altes ein, neues aus), Default beim
  ersten Render: alle inaktiven Projekte eingeklappt.
- File-Manager Project-Tagging:
  - diagnostic/server.js: Manifest /shared/config/file_projects.json
    + /api/files-list returnt projectId pro Datei + neuer Endpoint
    /api/files-set-project.
  - bridge/aria_bridge.py: nach App-Upload Auto-Tag mit aktivem Projekt
    (Brain-Status-Query, best-effort fail-silent).
  - App SettingsScreen: scrollbare Projekt-Pill-Reihe als Filter, default
    auf aktives Projekt wenn vorhanden, sonst "Alle Projekte".
  - Diagnostic: zweites Dropdown im Files-Tab, baut Projekt-Optionen
    dynamisch aus /api/brain/projects/list.

Bewusst nicht drin (Folgeschritt):
- Per-File "Projekt zuweisen"-Action (Long-Press / Right-Click)
- Filter-Sync zwischen ChatScreen-Banner und SettingsScreen-Filter

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 21:55:02 +02:00
duffyduck 1baa1a7a08 release: bump version to 0.1.9.4 2026-06-13 13:54:12 +02:00
duffyduck fc0f91d1e6 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>
2026-06-13 13:51:26 +02:00
14 changed files with 1585 additions and 28 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10903
versionName "0.1.9.3"
versionCode 10905
versionName "0.1.9.5"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.9.3",
"version": "0.1.9.5",
"private": true,
"scripts": {
"android": "react-native run-android",
+365
View File
@@ -0,0 +1,365 @@
/**
* Projekt-Übersicht + Switcher.
*
* Modal-Komponente die:
* - Den aktuellen Projekt-Status zeigt (Hauptchat oder konkretes Projekt)
* - Die Projekt-Liste rendert (sortiert nach letzter Aktivität)
* - Per Tap zwischen Projekten wechseln lässt
* - Neue Projekte anlegen kann
* - Bestehende editieren/beenden/archivieren
*
* Eingesetzt von ChatScreen (über den Projekt-Indicator) und von
* SettingsScreen.tsx in der Section 'projects'.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Project } from '../services/brainApi';
import rvs from '../services/rvs';
interface Props {
/** Optional — wenn als Modal genutzt, sonst inline */
visible?: boolean;
onClose?: () => void;
/** Wird gerufen wenn 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('');
// Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt
// wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes
// Render → useCallback re-runs → useEffect refeuert → infinite spinner).
const onActiveChangedRef = useRef(onActiveChanged);
useEffect(() => { onActiveChangedRef.current = onActiveChanged; }, [onActiveChanged]);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.getProjectStatus()
.then(status => {
setProjects(status.projects || []);
setActiveId(status.active_id || '');
onActiveChangedRef.current?.(status.active);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { if (visible) load(); }, [visible, load]);
// Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig
useEffect(() => {
if (!visible) return;
const unsub = rvs.onStateChange((state) => { if (state === 'connected') load(); });
return () => unsub();
}, [visible, load]);
const switchTo = useCallback((id: string) => {
brainApi.switchProject(id)
.then(status => {
setActiveId(status.active_id || '');
onActiveChangedRef.current?.(status.active);
})
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, []);
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;
+202 -4
View File
@@ -36,6 +36,8 @@ import ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound';
import {
@@ -68,6 +70,9 @@ interface ChatMessage {
text: string;
timestamp: number;
attachments?: Attachment[];
/** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
* Projekt-Bloecken zu gruppieren (auf/einklappbar). */
projectId?: string;
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
@@ -280,6 +285,10 @@ 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);
// Lookup-Map id → Projekt — fuer Header-Names im Chat-Verlauf
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
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 +466,26 @@ 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);
// Lookup-Map fuellen damit der Chat-Verlauf Header mit Namen rendern kann
const map: Record<string, string> = {};
for (const p of (s.projects || [])) map[p.id] = p.name;
if (s.active) map[s.active.id] = s.active.name;
setProjectNameById(prev => ({ ...prev, ...map }));
})
.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 +824,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 = {
@@ -1046,6 +1084,7 @@ const ChatScreen: React.FC = () => {
attachments: message.payload.attachments as Attachment[] | undefined,
messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
};
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
@@ -1588,7 +1627,87 @@ const ChatScreen: React.FC = () => {
() => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated),
[messages],
);
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
// Projekt-Bloecke: aufeinanderfolgende Nachrichten mit gleicher projectId
// werden visuell gruppiert. Erstes Message-Item einer Gruppe bekommt einen
// Header darueber (tappable für collapse). Collapsed → restliche Messages
// der Gruppe werden ausgefiltert; nur Header bleibt sichtbar.
// Hauptchat-Nachrichten (projectId leer) bleiben ungruppiert dazwischen.
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set());
const projectMeta = useMemo(() => {
// Pre-compute: welche Message ist Erst-Element ihrer Projekt-Gruppe?
// Plus: wieviele Messages pro Projekt insgesamt (fuer Header-Count).
const firstOfGroup = new Set<string>();
const counts = new Map<string, number>();
let lastPid: string | null = null;
for (const m of chatVisibleMessages) {
const pid = m.projectId || '';
if (pid) counts.set(pid, (counts.get(pid) || 0) + 1);
if (pid && pid !== lastPid) firstOfGroup.add(m.id);
lastPid = pid || null;
}
return { firstOfGroup, counts };
}, [chatVisibleMessages]);
// Render-Filter: bei collapsed Projekten zeigen wir NUR das erste Message-
// Item der Gruppe (das traegt den Header). Restliche Messages werden ausge-
// blendet — Header allein steht dann zwischen Hauptchat-Bubbles.
const messagesForRender = useMemo(() => {
return chatVisibleMessages.filter(m => {
const pid = m.projectId || '';
if (!pid) return true;
if (!collapsedProjects.has(pid)) return true;
return projectMeta.firstOfGroup.has(m.id);
});
}, [chatVisibleMessages, collapsedProjects, projectMeta]);
// Auto-Collapse beim Projekt-Wechsel: altes Projekt einklappen, neues aufklappen.
const prevActiveIdRef = useRef<string>('');
useEffect(() => {
const newActive = activeProject?.id || '';
const prevActive = prevActiveIdRef.current;
if (newActive === prevActive) return;
setCollapsedProjects(prev => {
const next = new Set(prev);
if (prevActive) next.add(prevActive);
if (newActive) next.delete(newActive);
return next;
});
prevActiveIdRef.current = newActive;
}, [activeProject]);
// Default: alle Projekte einklappen außer dem aktiven (Stefan-Vision:
// „beim ersten Öffnen sind alle projekte eingeklappt").
const collapseInitialized = useRef(false);
useEffect(() => {
if (collapseInitialized.current) return;
if (chatVisibleMessages.length === 0) return;
const allPids = new Set<string>();
for (const m of chatVisibleMessages) {
if (m.projectId) allPids.add(m.projectId);
}
if (allPids.size === 0) return;
const activePid = activeProject?.id || '';
setCollapsedProjects(prev => {
const next = new Set(prev);
for (const pid of allPids) {
if (pid !== activePid) next.add(pid);
}
return next;
});
collapseInitialized.current = true;
}, [chatVisibleMessages, activeProject]);
const toggleProjectCollapse = useCallback((projectId: string) => {
setCollapsedProjects(prev => {
const next = new Set(prev);
if (next.has(projectId)) next.delete(projectId); else next.add(projectId);
return next;
});
}, []);
const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]);
// Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
@@ -1790,6 +1909,7 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = activeProject?.id || '';
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
@@ -1798,16 +1918,18 @@ const ChatScreen: React.FC = () => {
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
@@ -2306,6 +2428,44 @@ const ChatScreen: React.FC = () => {
);
};
// Wrapper: setzt einen einklappbaren Projekt-Header VOR die Bubble wenn
// diese das erste Element ihrer Projekt-Gruppe ist. Bei collapsed
// → nur Header, keine Bubble. Hauptchat-Nachrichten gehen ohne Header durch.
const renderMessageWithProjectHeader = ({ item }: { item: ChatMessage }) => {
const pid = item.projectId || '';
if (!pid) return renderMessage({ item });
const isFirstOfGroup = projectMeta.firstOfGroup.has(item.id);
if (!isFirstOfGroup) return renderMessage({ item });
const isCollapsed = collapsedProjects.has(pid);
const projectName = projectNameById[pid] || pid;
const projectCount = projectMeta.counts.get(pid) || 0;
const header = (
<TouchableOpacity
onPress={() => toggleProjectCollapse(pid)}
style={{
marginVertical: 6, marginHorizontal: 8, paddingHorizontal: 10, paddingVertical: 8,
borderRadius: 6, backgroundColor: 'rgba(52,199,89,0.10)',
borderLeftWidth: 3, borderLeftColor: '#34C759',
flexDirection: 'row', alignItems: 'center', gap: 6,
}}
>
<Text style={{ fontSize: 14, color: '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
{isCollapsed ? '▶' : '▼'} 📁 {projectName}
</Text>
<Text style={{ fontSize: 11, color: '#8888AA' }}>
{projectCount} {projectCount === 1 ? 'Nachricht' : 'Nachrichten'}
</Text>
</TouchableOpacity>
);
if (isCollapsed) return header;
return (
<View>
{header}
{renderMessage({ item })}
</View>
);
};
// Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon).
// Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert
// teilen kann ohne den umliegenden Text mitzunehmen.
@@ -2457,6 +2617,44 @@ const ChatScreen: React.FC = () => {
);
})()}
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
<View
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',
}}
>
<TouchableOpacity onPress={() => setProjectsVisible(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
<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', marginRight: 8 }}>
{activeProject ? 'wechseln ' : 'Projekte '}
</Text>
</TouchableOpacity>
{activeProject && (
<TouchableOpacity
onPress={() => {
brainApi.switchProject('').then(s => setActiveProject(s.active || null)).catch(() => {});
}}
style={{ paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.08)' }}
hitSlop={{top:8,bottom:8,left:8,right:8}}
>
<Text style={{ fontSize: 11, color: '#E0E0F0', fontWeight: '600' }}>× Hauptchat</Text>
</TouchableOpacity>
)}
</View>
{/* Projekt-Modal */}
<ProjectsBrowser
visible={projectsVisible}
onClose={() => setProjectsVisible(false)}
onActiveChanged={(p) => setActiveProject(p)}
/>
{/* Suchleiste mit Treffer-Navigation */}
{searchVisible && (
<View style={styles.searchBar}>
@@ -2539,7 +2737,7 @@ const ChatScreen: React.FC = () => {
}, 300);
}}
keyExtractor={item => item.id}
renderItem={renderMessage}
renderItem={renderMessageWithProjectHeader}
contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
+61 -2
View File
@@ -92,6 +92,8 @@ import TriggerBrowser from '../components/TriggerBrowser';
import SkillBrowser from '../components/SkillBrowser';
import OAuthBrowser from '../components/OAuthBrowser';
import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi from '../services/brainApi';
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
import {
isWakeReadySoundEnabled,
@@ -142,6 +144,7 @@ const SETTINGS_SECTIONS = [
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
{ id: 'projects', icon: '📁', label: 'Projekte', desc: 'Thread-Bündel im Hauptchat — verwalten, wechseln, beenden' },
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
@@ -202,7 +205,9 @@ const SettingsScreen: React.FC = () => {
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
// Datei-Manager
const [fileManagerOpen, setFileManagerOpen] = useState(false);
const [fileManagerFiles, setFileManagerFiles] = useState<Array<{name: string; path: string; size: number; mtime: number; fromAria: boolean}>>([]);
const [fileManagerFiles, setFileManagerFiles] = useState<Array<{name: string; path: string; size: number; mtime: number; fromAria: boolean; projectId?: string}>>([]);
const [fileFilterProjectId, setFileFilterProjectId] = useState<string>('__all__');
const [fileFilterProjects, setFileFilterProjects] = useState<Array<{id: string; name: string}>>([]);
const [fileManagerLoading, setFileManagerLoading] = useState(false);
const [fileManagerError, setFileManagerError] = useState('');
const [fileManagerSearch, setFileManagerSearch] = useState('');
@@ -724,6 +729,20 @@ const SettingsScreen: React.FC = () => {
return () => unsub();
}, [fileManagerOpen]);
// Beim Oeffnen des Datei-Managers: Projekt-Liste laden fuer den Filter.
useEffect(() => {
if (!fileManagerOpen) return;
brainApi.listProjects(true)
.then(list => setFileFilterProjects(list.map(p => ({ id: p.id, name: p.name }))))
.catch(() => {});
// Default-Filter: aktives Projekt (falls vorhanden), sonst "alle".
brainApi.getProjectStatus()
.then(s => {
if (s.active_id) setFileFilterProjectId(s.active_id);
})
.catch(() => {});
}, [fileManagerOpen]);
// --- QR-Code scannen ---
const openQRScanner = useCallback(() => {
@@ -960,6 +979,29 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity>
))}
</View>
{/* Projekt-Filter: scrollbare Pill-Reihe. „Alle Projekte" + „Hauptchat" +
ein Pill pro Projekt. Default = aktives Projekt (siehe useEffect oben). */}
<ScrollView horizontal showsHorizontalScrollIndicator={false}
style={{marginTop:6}} contentContainerStyle={{gap:6, paddingRight:8}}>
{[
{ id: '__all__', name: '📁 Alle Projekte' },
{ id: '', name: '💬 Hauptchat' },
...fileFilterProjects,
].map(p => (
<TouchableOpacity
key={p.id || 'mainchat'}
onPress={() => setFileFilterProjectId(p.id)}
style={{
paddingVertical:6, paddingHorizontal:12, borderRadius:14,
backgroundColor: fileFilterProjectId === p.id ? '#34C759' : '#1E1E2E',
}}
>
<Text style={{color: fileFilterProjectId === p.id ? '#fff' : '#8888AA', fontSize:12}}>
{p.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{fileManagerLoading ? (
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
@@ -970,6 +1012,11 @@ const SettingsScreen: React.FC = () => {
let files = fileManagerFiles;
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
// Projekt-Filter: '__all__' = alles, '' = Hauptchat (kein project_id),
// sonst exakte project_id-Match.
if (fileFilterProjectId !== '__all__') {
files = files.filter(f => (f.projectId || '') === fileFilterProjectId);
}
if (fileManagerSearch) {
const q = fileManagerSearch.toLowerCase();
files = files.filter(f => f.name.toLowerCase().includes(q));
@@ -1280,7 +1327,7 @@ const SettingsScreen: React.FC = () => {
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
// scrolling laesst sonst nur in eine Richtung scrollen.
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth' && currentSection !== 'projects'}
>
{currentSection === null && (
@@ -2189,6 +2236,18 @@ const SettingsScreen: React.FC = () => {
</View>
</>)}
{/* === Projekte === */}
{currentSection === 'projects' && (<>
<Text style={styles.sectionTitle}>Projekte</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Thread-Bündel im Hauptchat. Tap auf ein Projekt aktivieren, alle weiteren Nachrichten gehen
dort rein. Long-Press bearbeiten. + Neu" oder zu ARIA: „lass uns ein Projekt anlegen".
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<ProjectsBrowser />
</View>
</>)}
{/* === Gedaechtnis === */}
{currentSection === 'memory' && (<>
<Text style={styles.sectionTitle}>Gedächtnis</Text>
+69
View File
@@ -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;
+244 -8
View File
@@ -32,6 +32,7 @@ import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
import oauth as oauth_mod
import projects as projects_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -808,6 +809,104 @@ META_TOOLS = [
},
},
},
# ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
{
"type": "function",
"function": {
"name": "project_create",
"description": (
"Legt ein neues Projekt an und macht es ZUR AKTIVEN Bühne. "
"Nutze das wenn Stefan sagt 'lass uns ein Projekt für X anlegen' "
"oder ein Thema klar als zusammenhängend bezeichnet. NICHT für "
"Ad-hoc-Fragen — Projekte sind für wiederkehrende, mehrere Tage "
"spannende Themen (Spotify-Setup, Renovierung, Reise-Planung)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Kurzer Name, wie ein Buchtitel ('Aria-Wakeword', 'Frankreich-Urlaub')."},
"description": {"type": "string", "description": "1-Satz worum's geht. Hilft beim Wiedererkennen."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_enter",
"description": (
"Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — "
"'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt "
"tagged jeder neue Turn die project_id. Bei sehr alten Projekten: "
"vorher project_summary aufrufen damit Du Stefan abholst."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name oder Teil davon."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_exit",
"description": (
"Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze "
"wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "project_list",
"description": "Listet alle Projekte mit Status und letzter Aktivität. Bevor Du ein neues anlegst: hier prüfen ob's schon eins gibt.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "project_summary",
"description": (
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
"halluzinierst Du Inhalte die nicht da sind."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_end",
"description": (
"Markiert ein Projekt als beendet — bleibt in der Liste sichtbar "
"(z.B. archiviert/grau), kann aber nicht mehr neu betreten werden "
"außer mit explizitem project_enter. Nutze wenn Stefan sagt 'Projekt "
"abgeschlossen' o.ä."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name."},
},
"required": ["name"],
},
},
},
]
@@ -960,17 +1059,28 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der
# neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt.
active_project_id = projects_mod.get_active()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
fast_reply = self._try_skill_fast_path(user_message)
if fast_reply is not None:
self.conversation.add("user", user_message, source=source)
self.conversation.add("assistant", fast_reply)
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
return fast_reply
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source)
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -1017,13 +1127,38 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
if active_project:
system_prompt += (
f"\n\n## AKTUELLES PROJEKT\n"
f"Stefan befindet sich gerade IN dem Projekt '{active_project['name']}' "
f"(id={active_project['id']}). Beschreibung: "
f"{active_project.get('description', '(keine)')}. "
f"Alle Antworten in diesem Turn gelten fuer dieses Projekt. "
f"Wenn er rauswill, ruf project_exit auf."
)
else:
project_count = len(projects_mod.list_projects())
if project_count > 0:
system_prompt += (
f"\n\n## PROJEKTE\n"
f"Hauptthread aktiv. {project_count} Projekte verfuegbar — wenn "
f"Stefan sagt 'in Projekt X' oder 'lass uns das Spotify-Thema "
f"weiterfuehren': project_enter aufrufen."
)
messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window():
# Conversation-Window auf das aktive Projekt filtern: in einem Projekt
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
# nur die nicht-getaggten Turns.
window = self.conversation.window(project_id=active_project_id)
for t in window:
messages.append(ProxyMessage(role=t.role, content=t.content))
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d prompt_chars=%d",
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
len(hot), len(cold), len(active_skills), len(all_skills),
len(self.conversation.window()), len(system_prompt))
len(window), active_project_id or "(main)", len(system_prompt))
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
@@ -1082,13 +1217,19 @@ class Agent:
err_text = f"[Fehler: {exc}]"
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
try:
self.conversation.add("assistant", err_text)
# Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops
# geaendert haben (project_enter/exit als Tool-Call).
self.conversation.add("assistant", err_text,
project_id=projects_mod.get_active())
except Exception as add_exc:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply)
# NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der
# Final-Reply schon im neuen Projekt-Kontext.
self.conversation.add("assistant", final_reply,
project_id=projects_mod.get_active())
return final_reply
# ── Tool-Dispatcher ───────────────────────────────────────
@@ -1648,6 +1789,101 @@ class Agent:
except Exception as e:
logger.exception("memory_save fehlgeschlagen")
return f"FEHLER beim Speichern: {e}"
# ── Projekte ────────────────────────────────────────
if name == "project_create":
pname = (arguments.get("name") or "").strip()
desc = (arguments.get("description") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
try:
p = projects_mod.create_project(pname, desc)
except ValueError as e:
return f"FEHLER: {e}"
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "created",
})
return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst."
if name == "project_enter":
pname = (arguments.get("name") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
projects_mod.set_active(p["id"])
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "entered",
})
turn_count = p.get("turn_count", 0)
hint = ""
if turn_count > 0:
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}"
if name == "project_exit":
active_id = projects_mod.get_active()
if not active_id:
return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread."
p = projects_mod.get_project(active_id)
projects_mod.set_active("")
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "exited",
})
return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread."
if name == "project_list":
items = projects_mod.list_projects()
if not items:
return "(keine Projekte angelegt)"
active_id = projects_mod.get_active()
lines = []
for p in items:
marker = " ← AKTIV" if p["id"] == active_id else ""
status_lbl = p.get("status", "active")
lines.append(
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
f"status={status_lbl}){marker}"
)
return "Projekte:\n" + "\n".join(lines)
if name == "project_summary":
pname = (arguments.get("name") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden."
# Letzte ~10 Turns des Projekts aus dem Conversation-Log
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
if not turns:
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
f"aktuellen Conversation-Window stehen noch keine Turns. "
f"Beschreibung: {p.get('description', '(keine)')}")
tail = turns[-12:]
summary_lines = []
for t in tail:
prefix = "Stefan" if t.role == "user" else "Du"
summary_lines.append(f"{prefix}: {t.content[:280]}")
preamble = (f"Projekt '{p['name']}'{p.get('description', '(keine Beschreibung)')}.\n"
f"Letzte {len(tail)} Turns:\n")
return preamble + "\n".join(summary_lines)
if name == "project_end":
pname = (arguments.get("name") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden."
projects_mod.end_project(p["id"])
self._pending_events.append({
"type": "project_changed",
"project": projects_mod.get_project(p["id"]),
"action": "ended",
})
return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
return f"Unbekanntes Tool: {name}"
except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name)
+38 -10
View File
@@ -32,6 +32,7 @@ class Turn:
content: str
ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
source: str = "" # "app" / "diagnostic" / "stt" — optional
project_id: str = "" # leer = Hauptthread; sonst projects.py-ID
class Conversation:
@@ -73,7 +74,8 @@ class Conversation:
if role in ("user", "assistant") and isinstance(content, str):
loaded.append(Turn(role=role, content=content,
ts=obj.get("ts", ""),
source=obj.get("source", "")))
source=obj.get("source", ""),
project_id=obj.get("project_id", "")))
self.turns = loaded
logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE)
@@ -85,17 +87,40 @@ class Conversation:
except Exception as exc:
logger.warning("Konversation persist fehlgeschlagen: %s", exc)
def add(self, role: str, content: str, source: str = "") -> Turn:
t = Turn(role=role, content=content, source=source)
def add(self, role: str, content: str, source: str = "",
project_id: str = "") -> Turn:
t = Turn(role=role, content=content, source=source, project_id=project_id)
self.turns.append(t)
self._append_to_file({
record = {
"ts": t.ts, "role": t.role, "content": t.content, "source": t.source,
})
}
if t.project_id:
record["project_id"] = t.project_id
self._append_to_file(record)
return t
def window(self) -> List[Turn]:
"""Die letzten max_window Turns — gehen in den LLM-Prompt."""
return self.turns[-self.max_window:]
def window(self, project_id: Optional[str] = None) -> List[Turn]:
"""Die letzten max_window Turns — gehen in den LLM-Prompt.
Wenn project_id gesetzt: nur Turns aus diesem Projekt + die letzten
~5 Hauptthread-Turns als Kontext. Wenn project_id leer/None und
explizit uebergeben → nur Hauptthread."""
if project_id is None:
return self.turns[-self.max_window:]
if project_id == "":
# Hauptthread-Modus: alle Turns, aber project-getaggte rausfiltern
main_turns = [t for t in self.turns if not t.project_id]
return main_turns[-self.max_window:]
# In-Projekt: alle Turns des Projekts + Tail des Hauptthreads als Kontext
project_turns = [t for t in self.turns if t.project_id == project_id]
return project_turns[-self.max_window:]
def window_recent_per_project(self) -> dict:
"""Returns {project_id: [last N turns]} — fuer „hol mich ab"-Summary."""
groups: dict[str, List[Turn]] = {}
for t in self.turns:
pid = t.project_id or ""
groups.setdefault(pid, []).append(t)
return groups
def needs_distill(self) -> bool:
return len(self.turns) > self.distill_threshold
@@ -131,10 +156,13 @@ class Conversation:
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
with tmp.open("w", encoding="utf-8") as f:
for t in self.turns:
f.write(json.dumps({
rec = {
"ts": t.ts, "role": t.role,
"content": t.content, "source": t.source,
}, ensure_ascii=False) + "\n")
}
if t.project_id:
rec["project_id"] = t.project_id
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
tmp.replace(CONVERSATION_FILE)
except Exception as exc:
logger.warning("Konversation rewrite fehlgeschlagen: %s", exc)
+77
View File
@@ -38,6 +38,7 @@ import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import projects as projects_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -613,6 +614,11 @@ class ChatOut(BaseModel):
turns: int
distilling: bool
events: list = Field(default_factory=list)
# Aktive Projekt-ID NACH dem Turn (kann durch project_enter/exit-Tools
# waehrend des Turns gewechselt haben). Bridge gibt das an die Chat-
# Bubble-Broadcasts weiter damit App + Diagnostic die Nachricht zum
# richtigen Projekt-Block sortieren koennen.
project_id: str = ""
@app.post("/chat", response_model=ChatOut)
@@ -636,9 +642,80 @@ def chat(body: ChatIn, background: BackgroundTasks):
turns=len(a.conversation.turns),
distilling=needs_distill,
events=a.pop_events(),
project_id=projects_mod.get_active(),
)
# ── Projekte ────────────────────────────────────────────────────────
@app.get("/projects/status")
def projects_status():
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
return projects_mod.status()
@app.get("/projects/list")
def projects_list(include_archived: bool = False):
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
class ProjectCreateBody(BaseModel):
name: str
description: str = ""
@app.post("/projects/create")
def projects_create(body: ProjectCreateBody):
try:
p = projects_mod.create_project(body.name, body.description)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return p
class ProjectSwitchBody(BaseModel):
project_id: str = ""
@app.post("/projects/switch")
def projects_switch(body: ProjectSwitchBody):
"""Aktive Projekt-ID setzen. Leerer String → Hauptthread."""
if body.project_id:
p = projects_mod.get_project(body.project_id)
if not p:
raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden")
projects_mod.set_active(body.project_id)
return projects_mod.status()
@app.post("/projects/{project_id}/end")
def projects_end(project_id: str):
if not projects_mod.end_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"}
@app.post("/projects/{project_id}/archive")
def projects_archive(project_id: str):
if not projects_mod.archive_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return {"id": project_id, "status": "archived"}
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@app.patch("/projects/{project_id}")
def projects_update(project_id: str, body: ProjectUpdateBody):
patch = body.dict(exclude_unset=True)
p = projects_mod.update_project(project_id, patch)
if p is None:
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return p
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()
+219
View File
@@ -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),
}
+58 -1
View File
@@ -1005,6 +1005,37 @@ class ARIABridge:
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned, files, missing
def _tag_file_to_active_project(self, file_path: str) -> None:
"""Holt vom Brain das aktive Projekt + schreibt file_path → project_id
in /shared/config/file_projects.json. Best-effort, fail-silent.
Wird vom File-Save-Handler nach erfolgreichem Schreiben aufgerufen."""
try:
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
with urllib.request.urlopen(f"{brain_url}/projects/status", timeout=5) as r:
data = json.loads(r.read())
active_id = (data.get("active_id") or "").strip()
if not active_id:
return
manifest_path = "/shared/config/file_projects.json"
os.makedirs("/shared/config", exist_ok=True)
try:
with open(manifest_path) as f:
manifest = json.load(f)
if not isinstance(manifest, dict):
manifest = {}
except FileNotFoundError:
manifest = {}
except Exception:
manifest = {}
manifest[file_path] = active_id
tmp = manifest_path + ".tmp"
with open(tmp, "w") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
os.replace(tmp, manifest_path)
logger.info("[file-project] %s%s", file_path, active_id)
except Exception as exc:
logger.warning("[file-project] tag failed (%s): %s", file_path, exc)
async def _broadcast_aria_file(self, file_info: dict) -> None:
"""ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren."""
logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)",
@@ -1224,6 +1255,9 @@ class ARIABridge:
"backupTs": assistant_backup_ts,
# Debug: aufbereiteter Text fuer TTS (App ignoriert, Diagnostic zeigt optional)
"ttsText": tts_text_preview if tts_text_preview != text else "",
# Projekt-Zuordnung — App + Diagnostic sortieren die Bubble in
# den passenden Projekt-Block. Leer = Hauptchat.
"projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -1521,6 +1555,11 @@ class ARIABridge:
await self._emit_activity("idle", "")
return
# Projekt-Kontext des Turns — wird an _process_core_response weiter-
# gegeben damit der chat-Broadcast die Bubble dem richtigen Projekt-
# Block in App + Diagnostic zuordnen kann.
turn_project_id = (data.get("project_id") or "").strip()
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen
for event in data.get("events", []) or []:
@@ -1564,6 +1603,20 @@ class ARIABridge:
logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)",
event.get("memory", {}).get("title"),
event.get("memory", {}).get("type"))
elif etype == "project_changed":
# ARIA hat ein Projekt erstellt / betreten / verlassen / beendet.
# App + Diagnostic refreshen ihren Projekt-Banner anhand des Events.
await self._send_to_rvs({
"type": "project_changed",
"payload": {
"action": event.get("action") or "",
**(event.get("project") or {}),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
proj = event.get("project") or {}
logger.info("[brain] Projekt %s: %s (id=%s)",
event.get("action") or "?", proj.get("name"), proj.get("id"))
# _process_core_response uebernimmt alles weitere:
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
@@ -1572,7 +1625,7 @@ class ARIABridge:
# passend behandelt wird (hier minimal, weil Brain noch keine
# metadata mitschickt).
try:
await self._process_core_response(reply, {})
await self._process_core_response(reply, {"projectId": turn_project_id})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "")
@@ -2111,6 +2164,10 @@ class ARIABridge:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# Datei dem aktuellen Projekt zuordnen (falls Stefan in einem ist).
# Manifest in /shared/config/file_projects.json — File-Manager
# in App + Diagnostic filtert danach.
self._tag_file_to_active_project(file_path)
# Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).
+192
View File
@@ -992,6 +992,41 @@
<!-- Alte Sessions-Sicherung entfernt — aria-core ist raus. -->
<!-- Projekte — Threads-im-Hauptchat-Konzept -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2>
<div>
<button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
<button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div>
</div>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Projekte bündeln zusammengehörige Turns als Block im Hauptchat. Stefan sagt zu ARIA
„lass uns ein Projekt anlegen" oder klickt hier auf „+ Neues Projekt". Aktives Projekt:
<span id="project-active-label" style="color:#34C759;font-weight:600;">(wird geladen...)</span>
</div>
<div id="project-list" class="card" style="padding:0;">
<div style="padding:14px;color:#8888AA;font-size:12px;">Lade Projekte...</div>
</div>
</div>
<!-- Neues-Projekt Modal -->
<div id="project-create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;align-items:center;justify-content:center;">
<div style="background:#15151E;padding:20px;border-radius:8px;min-width:340px;max-width:90vw;">
<h3 style="margin-top:0;color:#E0E0F0;">Neues Projekt</h3>
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Name</label>
<input type="text" id="project-create-name" placeholder="z.B. Frankreich-Urlaub"
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:14px;margin-bottom:10px;">
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Beschreibung (optional)</label>
<textarea id="project-create-desc" placeholder="1 Satz worum's geht. Hilft beim Wiederfinden."
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:13px;height:60px;resize:vertical;margin-bottom:14px;"></textarea>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn secondary" onclick="closeCreateProjectModal()" style="padding:6px 14px;font-size:12px;">Abbrechen</button>
<button class="btn primary" onclick="submitCreateProject()" style="padding:6px 14px;font-size:12px;">Anlegen + aktivieren</button>
</div>
</div>
</div>
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
@@ -1074,6 +1109,11 @@
<option value="aria">Von ARIA (aria_*)</option>
<option value="user">Vom Benutzer</option>
</select>
<select id="files-filter-project" onchange="renderFilesList()" style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
<option value="__all__">Alle Projekte</option>
<option value="">💬 Hauptchat</option>
<!-- Project options werden dynamisch via loadFiles() befuellt -->
</select>
</div>
<div id="files-info" style="margin-top:6px;font-size:10px;color:#8888AA;"></div>
</div>
@@ -1543,6 +1583,13 @@
return;
}
if (msg.type === 'project_changed') {
// ARIA hat in einem Tool-Call ein Projekt erstellt/betreten/verlassen/beendet.
// Liste neu laden falls sichtbar.
loadProjects();
return;
}
if (msg.type === 'voice_id_delete_response') {
const p = msg.payload || msg;
if (p.removed) {
@@ -2694,6 +2741,117 @@
send({ action: 'voice_id_delete' });
}
// ── Projekte ────────────────────────────────────────────
async function loadProjects() {
const listEl = document.getElementById('project-list');
const activeLabel = document.getElementById('project-active-label');
try {
const r = await fetch('/api/brain/projects/status');
const status = await r.json();
const projects = status.projects || [];
const activeId = status.active_id || '';
activeLabel.textContent = status.active ? status.active.name : '💬 Hauptchat';
activeLabel.style.color = status.active ? '#34C759' : '#8888AA';
const rows = [];
// Hauptchat-Eintrag
rows.push(`
<div onclick="switchProject('')" style="cursor:pointer;padding:12px 14px;border-bottom:1px solid #1E1E2E;${!activeId ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div>
<div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`);
for (const p of projects) {
const isActive = p.id === activeId;
const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
const ended = p.status === 'ended';
rows.push(`
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
📁 ${escapeHtml(p.name)}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<div style="display:flex;gap:4px;">
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div>
</div>
</div>`);
}
if (projects.length === 0) {
rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>');
}
listEl.innerHTML = rows.join('');
} catch (e) {
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`;
}
}
async function switchProject(projectId) {
try {
await fetch('/api/brain/projects/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: projectId }),
});
loadProjects();
} catch (e) { alert('Wechsel fehlgeschlagen: ' + e.message); }
}
async function endProject(id, name) {
if (!confirm(`Projekt "${name}" beenden?\n\nBleibt sichtbar, aktiv ist dann der Hauptchat.`)) return;
try {
await fetch(`/api/brain/projects/${encodeURIComponent(id)}/end`, { method: 'POST' });
loadProjects();
} catch (e) { alert('Beenden fehlgeschlagen: ' + e.message); }
}
async function archiveProject(id, name) {
if (!confirm(`Projekt "${name}" archivieren?\n\nVerschwindet aus der Liste.`)) return;
try {
await fetch(`/api/brain/projects/${encodeURIComponent(id)}/archive`, { method: 'POST' });
loadProjects();
} catch (e) { alert('Archivieren fehlgeschlagen: ' + e.message); }
}
function openCreateProjectModal() {
document.getElementById('project-create-name').value = '';
document.getElementById('project-create-desc').value = '';
document.getElementById('project-create-modal').style.display = 'flex';
setTimeout(() => document.getElementById('project-create-name').focus(), 50);
}
function closeCreateProjectModal() {
document.getElementById('project-create-modal').style.display = 'none';
}
async function submitCreateProject() {
const name = document.getElementById('project-create-name').value.trim();
const description = document.getElementById('project-create-desc').value.trim();
if (!name) { alert('Name darf nicht leer sein.'); return; }
try {
await fetch('/api/brain/projects/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
closeCreateProjectModal();
loadProjects();
} catch (e) { alert('Anlegen fehlgeschlagen: ' + e.message); }
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function escapeHtmlAttr(str) {
return String(str).replace(/['"\\]/g, '\\$&');
}
function deleteXttsVoice(name) {
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
send({ action: 'xtts_delete_voice', name });
@@ -3450,6 +3608,7 @@
loadBrainMemoryList();
refreshImportFiles();
loadMetrics();
loadProjects();
} else if (tab === 'files') {
loadFiles();
} else if (tab === 'skills') {
@@ -4055,6 +4214,37 @@
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler');
filesCache = d.files || [];
// Projekt-Filter-Optionen aktualisieren — Liste aller bekannten projectIds
// aus den Dateien + Namen via brain api.
const pidsInFiles = new Set(filesCache.map(f => f.projectId).filter(Boolean));
try {
const pr = await fetch('/api/brain/projects/list?include_archived=true');
const pdata = await pr.json();
const projects = pdata?.projects || [];
const sel = document.getElementById('files-filter-project');
if (sel) {
const current = sel.value;
// Bestehende Options ab Index 2 (nach __all__ und Hauptchat) entfernen
while (sel.options.length > 2) sel.remove(2);
for (const p of projects) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `📁 ${p.name}`;
sel.appendChild(opt);
}
// Auch IDs aus Files die nicht in projects sind (gelöschte Projekte)
const knownIds = new Set(projects.map(p => p.id));
for (const pid of pidsInFiles) {
if (!knownIds.has(pid)) {
const opt = document.createElement('option');
opt.value = pid;
opt.textContent = `📁 ${pid} (gelöscht?)`;
sel.appendChild(opt);
}
}
sel.value = current || '__all__';
}
} catch {}
// Selection bereinigen — nicht mehr existierende Pfade raus
const existing = new Set(filesCache.map(f => f.path));
for (const p of [...filesSelected]) if (!existing.has(p)) filesSelected.delete(p);
@@ -4067,9 +4257,11 @@
function getVisibleFiles() {
const q = (document.getElementById('files-search').value || '').toLowerCase();
const filter = document.getElementById('files-filter').value;
const pidFilter = document.getElementById('files-filter-project')?.value || '__all__';
let files = filesCache.slice();
if (filter === 'aria') files = files.filter(f => f.fromAria);
else if (filter === 'user') files = files.filter(f => !f.fromAria);
if (pidFilter !== '__all__') files = files.filter(f => (f.projectId || '') === pidFilter);
if (q) files = files.filter(f => f.name.toLowerCase().includes(q));
return files;
}
+53
View File
@@ -297,6 +297,32 @@ function writeRuntimeConfig(patch) {
}
// Atomic write: temp-file + rename, laute Logs bei Fehler.
// ── File-Project-Manifest ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt.
const FILE_PROJECTS_FILE = "/shared/config/file_projects.json";
function loadFileProjects() {
try {
if (!fs.existsSync(FILE_PROJECTS_FILE)) return {};
const data = JSON.parse(fs.readFileSync(FILE_PROJECTS_FILE, "utf-8"));
return (data && typeof data === "object") ? data : {};
} catch {
return {};
}
}
function saveFileProjects(manifest) {
try {
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = FILE_PROJECTS_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
fs.renameSync(tmp, FILE_PROJECTS_FILE);
} catch (err) {
log("warn", "files", `file-projects-Manifest schreiben fehlgeschlagen: ${err.message}`);
}
}
function persistActiveSession(key) {
try {
const tmp = SESSION_KEY_FILE + ".tmp";
@@ -1598,6 +1624,7 @@ const server = http.createServer((req, res) => {
const dir = "/shared/uploads";
let entries = [];
try { entries = fs.readdirSync(dir); } catch { entries = []; }
const manifest = loadFileProjects();
const files = entries
.map(name => {
try {
@@ -1610,6 +1637,7 @@ const server = http.createServer((req, res) => {
size: st.size,
mtime: Math.floor(st.mtimeMs),
fromAria: name.startsWith("aria_"),
projectId: manifest[full] || '',
};
} catch { return null; }
})
@@ -1622,6 +1650,31 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ ok: false, error: err.message }));
}
return;
} else if (req.url === "/api/files-set-project" && req.method === "POST") {
// Body: { path, projectId } — projectId leer = Hauptchat (= Eintrag entfernen)
let body = "";
req.on("data", c => { body += c; if (body.length > 8192) req.destroy(); });
req.on("end", () => {
try {
const data = JSON.parse(body || "{}");
const fpath = String(data.path || "");
const pid = String(data.projectId || "");
if (!fpath.startsWith("/shared/uploads/") || !fs.existsSync(fpath)) {
res.writeHead(404, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: false, error: "Datei nicht gefunden" }));
}
const manifest = loadFileProjects();
if (pid) manifest[fpath] = pid;
else delete manifest[fpath];
saveFileProjects(manifest);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, path: fpath, projectId: pid }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if ((req.url.startsWith("/api/files-download?") || req.url.startsWith("/api/files-view?")) && req.method === "GET") {
// /api/files-download → mit Content-Disposition:attachment (Browser downloaded)
// /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab)
+4
View File
@@ -48,6 +48,10 @@ const ALLOWED_TYPES = new Set([
"voice_id_status_request", "voice_id_status_response",
"voice_id_enroll_request", "voice_id_enroll_response",
"voice_id_delete_request", "voice_id_delete_response",
// Projekte (Stefan-Konzept: Threads im Hauptchat verankert) — Side-Channel-
// Event vom Brain → Bridge → App/Diagnostic, damit beide Clients ihren
// aktiven-Projekt-Banner refreshen wenn ARIA via Tool was aendert.
"project_changed",
// File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
"file_version_list_request", "file_version_list_response",