Compare commits

...

4 Commits

Author SHA1 Message Date
duffyduck 2a2700907c release: bump version to 0.1.9.6 2026-06-13 22:09:56 +02:00
duffyduck 93ecbf6c43 fix(projects): ARIA-generierte Dateien dem aktiven Projekt zuordnen
Bisher haben nur App-Uploads (msg_type == "file") ein Projekt-Tag bekommen.
Dateien die ARIA waehrend des Turns selbst schreibt (via [FILE: /shared/
uploads/aria_xyz.pdf]-Marker) sind dem Hauptchat zugefallen, auch wenn
Stefan in einem Projekt war.

Fix: Beim Verarbeiten der ARIA-Antwort in _process_core_response wird die
turn_project_id aus payload.projectId (ChatOut.project_id vom Brain) ge-
nutzt um jede gefundene ARIA-Datei sofort zu taggen, bevor sie als
file_from_aria broadcast wird.

Helper-Split:
- _tag_file_to_project(path, pid): pure Write, pid schon bekannt
- _tag_file_to_active_project(path): Convenience-Wrapper, fragt Brain
  nach active project (genutzt vom App-Upload-Handler, der noch nichts
  vom Projekt-Kontext weiss)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 22:08:23 +02:00
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
9 changed files with 383 additions and 25 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10904 versionCode 10906
versionName "0.1.9.4" versionName "0.1.9.6"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.9.4", "version": "0.1.9.6",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+11 -5
View File
@@ -12,7 +12,7 @@
* SettingsScreen.tsx in der Section 'projects'. * SettingsScreen.tsx in der Section 'projects'.
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -60,17 +60,23 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = 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(() => { const load = useCallback(() => {
setLoading(true); setErr(null); setLoading(true); setErr(null);
brainApi.getProjectStatus() brainApi.getProjectStatus()
.then(status => { .then(status => {
setProjects(status.projects || []); setProjects(status.projects || []);
setActiveId(status.active_id || ''); setActiveId(status.active_id || '');
onActiveChanged?.(status.active); onActiveChangedRef.current?.(status.active);
}) })
.catch(e => setErr(String(e?.message || e))) .catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [onActiveChanged]); }, []);
useEffect(() => { if (visible) load(); }, [visible, load]); useEffect(() => { if (visible) load(); }, [visible, load]);
@@ -85,10 +91,10 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
brainApi.switchProject(id) brainApi.switchProject(id)
.then(status => { .then(status => {
setActiveId(status.active_id || ''); setActiveId(status.active_id || '');
onActiveChanged?.(status.active); onActiveChangedRef.current?.(status.active);
}) })
.catch(e => Alert.alert('Fehler', String(e?.message || e))); .catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [onActiveChanged]); }, []);
const createProject = useCallback(() => { const createProject = useCallback(() => {
const name = newName.trim(); const name = newName.trim();
+161 -15
View File
@@ -70,6 +70,9 @@ interface ChatMessage {
text: string; text: string;
timestamp: number; timestamp: number;
attachments?: Attachment[]; 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 */ /** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string; messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */ /** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
@@ -284,6 +287,8 @@ const ChatScreen: React.FC = () => {
const [searchVisible, setSearchVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false);
const [projectsVisible, setProjectsVisible] = useState(false); const [projectsVisible, setProjectsVisible] = useState(false);
const [activeProject, setActiveProject] = useState<BrainProject | null>(null); 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 [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
@@ -466,7 +471,14 @@ const ChatScreen: React.FC = () => {
useEffect(() => { useEffect(() => {
const loadProject = () => { const loadProject = () => {
brainApi.getProjectStatus() brainApi.getProjectStatus()
.then(s => setActiveProject(s.active || null)) .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(() => {}); .catch(() => {});
}; };
loadProject(); loadProject();
@@ -1072,6 +1084,7 @@ const ChatScreen: React.FC = () => {
attachments: message.payload.attachments as Attachment[] | undefined, attachments: message.payload.attachments as Attachment[] | undefined,
messageId: (message.payload.messageId as string) || undefined, messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || 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' // ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet. // markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
@@ -1614,7 +1627,87 @@ const ChatScreen: React.FC = () => {
() => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated), () => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated),
[messages], [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 — // Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste // analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
@@ -1816,6 +1909,7 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const cmid = nextClientMsgId(); const cmid = nextClientMsgId();
const activePid = activeProject?.id || '';
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
@@ -1824,16 +1918,18 @@ const ChatScreen: React.FC = () => {
clientMsgId: cmid, clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued', deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1, sendAttempts: 1,
projectId: activePid,
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s', console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted); cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', { dispatchWithAck(cmid, 'chat', {
text, text,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted, interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }), ...(location && { location }),
}); });
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]); }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
@@ -2332,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). // Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon).
// Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert // Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert
// teilen kann ohne den umliegenden Text mitzunehmen. // teilen kann ohne den umliegenden Text mitzunehmen.
@@ -2483,9 +2617,8 @@ const ChatScreen: React.FC = () => {
); );
})()} })()}
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */} {/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
<TouchableOpacity <View
onPress={() => setProjectsVisible(true)}
style={{ style={{
flexDirection: 'row', alignItems: 'center', flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 12, paddingVertical: 6, paddingHorizontal: 12, paddingVertical: 6,
@@ -2494,13 +2627,26 @@ const ChatScreen: React.FC = () => {
borderColor: activeProject ? '#34C759' : '#1E1E2E', borderColor: activeProject ? '#34C759' : '#1E1E2E',
}} }}
> >
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}> <TouchableOpacity onPress={() => setProjectsVisible(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'} <Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
</Text> {activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
<Text style={{ fontSize: 11, color: '#555570' }}> </Text>
{activeProject ? 'wechseln ' : 'Projekte '} <Text style={{ fontSize: 11, color: '#555570', marginRight: 8 }}>
</Text> {activeProject ? 'wechseln ' : 'Projekte '}
</TouchableOpacity> </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 */} {/* Projekt-Modal */}
<ProjectsBrowser <ProjectsBrowser
@@ -2591,7 +2737,7 @@ const ChatScreen: React.FC = () => {
}, 300); }, 300);
}} }}
keyExtractor={item => item.id} keyExtractor={item => item.id}
renderItem={renderMessage} renderItem={renderMessageWithProjectHeader}
contentContainerStyle={styles.messageList} contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
ListEmptyComponent={ ListEmptyComponent={
+46 -1
View File
@@ -93,6 +93,7 @@ import SkillBrowser from '../components/SkillBrowser';
import OAuthBrowser from '../components/OAuthBrowser'; import OAuthBrowser from '../components/OAuthBrowser';
import VoiceIdEnrollment from '../components/VoiceIdEnrollment'; import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
import ProjectsBrowser from '../components/ProjectsBrowser'; import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi from '../services/brainApi';
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger'; import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
import { import {
isWakeReadySoundEnabled, isWakeReadySoundEnabled,
@@ -204,7 +205,9 @@ const SettingsScreen: React.FC = () => {
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]); const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
// Datei-Manager // Datei-Manager
const [fileManagerOpen, setFileManagerOpen] = useState(false); 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 [fileManagerLoading, setFileManagerLoading] = useState(false);
const [fileManagerError, setFileManagerError] = useState(''); const [fileManagerError, setFileManagerError] = useState('');
const [fileManagerSearch, setFileManagerSearch] = useState(''); const [fileManagerSearch, setFileManagerSearch] = useState('');
@@ -726,6 +729,20 @@ const SettingsScreen: React.FC = () => {
return () => unsub(); return () => unsub();
}, [fileManagerOpen]); }, [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 --- // --- QR-Code scannen ---
const openQRScanner = useCallback(() => { const openQRScanner = useCallback(() => {
@@ -962,6 +979,29 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </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> </View>
{fileManagerLoading ? ( {fileManagerLoading ? (
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text> <Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
@@ -972,6 +1012,11 @@ const SettingsScreen: React.FC = () => {
let files = fileManagerFiles; let files = fileManagerFiles;
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria); if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
else if (fileManagerFilter === 'user') 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) { if (fileManagerSearch) {
const q = fileManagerSearch.toLowerCase(); const q = fileManagerSearch.toLowerCase();
files = files.filter(f => f.name.toLowerCase().includes(q)); files = files.filter(f => f.name.toLowerCase().includes(q));
+6
View File
@@ -614,6 +614,11 @@ class ChatOut(BaseModel):
turns: int turns: int
distilling: bool distilling: bool
events: list = Field(default_factory=list) 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) @app.post("/chat", response_model=ChatOut)
@@ -637,6 +642,7 @@ def chat(body: ChatIn, background: BackgroundTasks):
turns=len(a.conversation.turns), turns=len(a.conversation.turns),
distilling=needs_distill, distilling=needs_distill,
events=a.pop_events(), events=a.pop_events(),
project_id=projects_mod.get_active(),
) )
+65 -1
View File
@@ -1005,6 +1005,50 @@ class ARIABridge:
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned, files, missing return cleaned, files, missing
def _tag_file_to_project(self, file_path: str, project_id: str) -> None:
"""Schreibt file_path → project_id in /shared/config/file_projects.json.
Best-effort, fail-silent. project_id leer = Eintrag entfernen (Hauptchat)."""
try:
manifest_path = "/shared/config/file_projects.json"
os.makedirs("/shared/config", exist_ok=True)
try:
with open(manifest_path) as f:
manifest = json.load(f)
if not isinstance(manifest, dict):
manifest = {}
except FileNotFoundError:
manifest = {}
except Exception:
manifest = {}
if project_id:
manifest[file_path] = project_id
else:
manifest.pop(file_path, None)
tmp = manifest_path + ".tmp"
with open(tmp, "w") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
os.replace(tmp, manifest_path)
logger.info("[file-project] %s%s", file_path, project_id or "(main)")
except Exception as exc:
logger.warning("[file-project] tag failed (%s): %s", file_path, exc)
def _tag_file_to_active_project(self, file_path: str) -> None:
"""Convenience: Brain nach aktivem Projekt fragen + taggen.
Wird vom App-Upload-Handler genutzt (dort wissen wir die Projekt-ID
noch nicht aus dem Payload — Stefan kann ja zwischen App-Upload und
Chat-Send das Projekt gewechselt haben). ARIA-eigene Dateien gehen
ueber _tag_file_to_project mit turn_project_id direkt."""
try:
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
with urllib.request.urlopen(f"{brain_url}/projects/status", timeout=5) as r:
data = json.loads(r.read())
active_id = (data.get("active_id") or "").strip()
if not active_id:
return
self._tag_file_to_project(file_path, active_id)
except Exception as exc:
logger.warning("[file-project] active-query failed (%s): %s", file_path, exc)
async def _broadcast_aria_file(self, file_info: dict) -> None: async def _broadcast_aria_file(self, file_info: dict) -> None:
"""ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren.""" """ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren."""
logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)", logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)",
@@ -1163,7 +1207,15 @@ class ARIABridge:
# Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht # Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht
# vorlesen) und parallel als file_from_aria-Event geschickt. # vorlesen) und parallel als file_from_aria-Event geschickt.
text, aria_files, missing_files = self._extract_file_markers(text) text, aria_files, missing_files = self._extract_file_markers(text)
# ARIA-Dateien dem aktiven Projekt zuordnen (falls eines aktiv war).
# turn_project_id kommt vom Brain mit dem /chat-Response und reflektiert
# den Stand NACH dem Turn — passt fuer Dateien die ARIA waehrend des
# Turns geschrieben hat (sie sind „im selben Projekt entstanden").
turn_pid = (payload.get("projectId") or "").strip() if isinstance(payload, dict) else ""
for f in aria_files: for f in aria_files:
server_path = f.get("serverPath")
if turn_pid and server_path:
self._tag_file_to_project(server_path, turn_pid)
await self._broadcast_aria_file(f) await self._broadcast_aria_file(f)
# Bei fehlenden Files: User informieren (sonst sieht er nur stille # Bei fehlenden Files: User informieren (sonst sieht er nur stille
# Verluste — ARIA hat den Marker hingeschrieben aber das File nicht # Verluste — ARIA hat den Marker hingeschrieben aber das File nicht
@@ -1224,6 +1276,9 @@ class ARIABridge:
"backupTs": assistant_backup_ts, "backupTs": assistant_backup_ts,
# Debug: aufbereiteter Text fuer TTS (App ignoriert, Diagnostic zeigt optional) # Debug: aufbereiteter Text fuer TTS (App ignoriert, Diagnostic zeigt optional)
"ttsText": tts_text_preview if tts_text_preview != text else "", "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), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -1521,6 +1576,11 @@ class ARIABridge:
await self._emit_activity("idle", "") await self._emit_activity("idle", "")
return 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) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
for event in data.get("events", []) or []: for event in data.get("events", []) or []:
@@ -1586,7 +1646,7 @@ class ARIABridge:
# passend behandelt wird (hier minimal, weil Brain noch keine # passend behandelt wird (hier minimal, weil Brain noch keine
# metadata mitschickt). # metadata mitschickt).
try: try:
await self._process_core_response(reply, {}) await self._process_core_response(reply, {"projectId": turn_project_id})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "") await self._emit_activity("idle", "")
@@ -2125,6 +2185,10 @@ class ARIABridge:
f.write(base64.b64decode(file_b64)) f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365 size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb) 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 # Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten). # bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).
+38
View File
@@ -1109,6 +1109,11 @@
<option value="aria">Von ARIA (aria_*)</option> <option value="aria">Von ARIA (aria_*)</option>
<option value="user">Vom Benutzer</option> <option value="user">Vom Benutzer</option>
</select> </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>
<div id="files-info" style="margin-top:6px;font-size:10px;color:#8888AA;"></div> <div id="files-info" style="margin-top:6px;font-size:10px;color:#8888AA;"></div>
</div> </div>
@@ -4209,6 +4214,37 @@
const d = await r.json(); const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler'); if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler');
filesCache = d.files || []; 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 // Selection bereinigen — nicht mehr existierende Pfade raus
const existing = new Set(filesCache.map(f => f.path)); const existing = new Set(filesCache.map(f => f.path));
for (const p of [...filesSelected]) if (!existing.has(p)) filesSelected.delete(p); for (const p of [...filesSelected]) if (!existing.has(p)) filesSelected.delete(p);
@@ -4221,9 +4257,11 @@
function getVisibleFiles() { function getVisibleFiles() {
const q = (document.getElementById('files-search').value || '').toLowerCase(); const q = (document.getElementById('files-search').value || '').toLowerCase();
const filter = document.getElementById('files-filter').value; const filter = document.getElementById('files-filter').value;
const pidFilter = document.getElementById('files-filter-project')?.value || '__all__';
let files = filesCache.slice(); let files = filesCache.slice();
if (filter === 'aria') files = files.filter(f => f.fromAria); if (filter === 'aria') files = files.filter(f => f.fromAria);
else if (filter === 'user') 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)); if (q) files = files.filter(f => f.name.toLowerCase().includes(q));
return files; return files;
} }
+53
View File
@@ -297,6 +297,32 @@ function writeRuntimeConfig(patch) {
} }
// Atomic write: temp-file + rename, laute Logs bei Fehler. // 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) { function persistActiveSession(key) {
try { try {
const tmp = SESSION_KEY_FILE + ".tmp"; const tmp = SESSION_KEY_FILE + ".tmp";
@@ -1598,6 +1624,7 @@ const server = http.createServer((req, res) => {
const dir = "/shared/uploads"; const dir = "/shared/uploads";
let entries = []; let entries = [];
try { entries = fs.readdirSync(dir); } catch { entries = []; } try { entries = fs.readdirSync(dir); } catch { entries = []; }
const manifest = loadFileProjects();
const files = entries const files = entries
.map(name => { .map(name => {
try { try {
@@ -1610,6 +1637,7 @@ const server = http.createServer((req, res) => {
size: st.size, size: st.size,
mtime: Math.floor(st.mtimeMs), mtime: Math.floor(st.mtimeMs),
fromAria: name.startsWith("aria_"), fromAria: name.startsWith("aria_"),
projectId: manifest[full] || '',
}; };
} catch { return null; } } catch { return null; }
}) })
@@ -1622,6 +1650,31 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ ok: false, error: err.message })); res.end(JSON.stringify({ ok: false, error: err.message }));
} }
return; 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") { } 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-download → mit Content-Disposition:attachment (Browser downloaded)
// /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab) // /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab)