From 1fb512c2fd3ce80ba8ac8ab74ab4065d8b5027d8 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 13 Jun 2026 21:55:02 +0200 Subject: [PATCH] fix+feat(projects): Spinner-Bug, Back-Button, kollabierbare Chat-Bloecke, File-Filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/src/components/ProjectsBrowser.tsx | 16 +- android/src/screens/ChatScreen.tsx | 176 +++++++++++++++++++-- android/src/screens/SettingsScreen.tsx | 47 +++++- aria-brain/main.py | 6 + bridge/aria_bridge.py | 45 +++++- diagnostic/index.html | 38 +++++ diagnostic/server.js | 53 +++++++ 7 files changed, 359 insertions(+), 22 deletions(-) diff --git a/android/src/components/ProjectsBrowser.tsx b/android/src/components/ProjectsBrowser.tsx index 88a74ff..fa5c2c7 100644 --- a/android/src/components/ProjectsBrowser.tsx +++ b/android/src/components/ProjectsBrowser.tsx @@ -12,7 +12,7 @@ * SettingsScreen.tsx in der Section 'projects'. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, @@ -60,17 +60,23 @@ export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onAc 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 || ''); - onActiveChanged?.(status.active); + onActiveChangedRef.current?.(status.active); }) .catch(e => setErr(String(e?.message || e))) .finally(() => setLoading(false)); - }, [onActiveChanged]); + }, []); useEffect(() => { if (visible) load(); }, [visible, load]); @@ -85,10 +91,10 @@ export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onAc brainApi.switchProject(id) .then(status => { setActiveId(status.active_id || ''); - onActiveChanged?.(status.active); + onActiveChangedRef.current?.(status.active); }) .catch(e => Alert.alert('Fehler', String(e?.message || e))); - }, [onActiveChanged]); + }, []); const createProject = useCallback(() => { const name = newName.trim(); diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 461b38e..9545317 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -70,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://...) */ @@ -284,6 +287,8 @@ const ChatScreen: React.FC = () => { const [searchVisible, setSearchVisible] = useState(false); const [projectsVisible, setProjectsVisible] = useState(false); const [activeProject, setActiveProject] = useState(null); + // Lookup-Map id → Projekt — fuer Header-Names im Chat-Verlauf + const [projectNameById, setProjectNameById] = useState>({}); 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: ''}); @@ -466,7 +471,14 @@ const ChatScreen: React.FC = () => { useEffect(() => { const loadProject = () => { 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 = {}; + 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(); @@ -1072,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. @@ -1614,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>(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(); + const counts = new Map(); + 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(''); + 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(); + 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 @@ -1816,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', @@ -1824,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]); @@ -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 = ( + 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, + }} + > + + {isCollapsed ? '▶' : '▼'} 📁 {projectName} + + + {projectCount} {projectCount === 1 ? 'Nachricht' : 'Nachrichten'} + + + ); + if (isCollapsed) return header; + return ( + + {header} + {renderMessage({ item })} + + ); + }; + // 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. @@ -2483,9 +2617,8 @@ const ChatScreen: React.FC = () => { ); })()} - {/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */} - setProjectsVisible(true)} + {/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */} + { borderColor: activeProject ? '#34C759' : '#1E1E2E', }} > - - {activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'} - - - {activeProject ? 'wechseln ›' : 'Projekte ›'} - - + setProjectsVisible(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}> + + {activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'} + + + {activeProject ? 'wechseln ›' : 'Projekte ›'} + + + {activeProject && ( + { + 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}} + > + × Hauptchat + + )} + {/* Projekt-Modal */} { }, 300); }} keyExtractor={item => item.id} - renderItem={renderMessage} + renderItem={renderMessageWithProjectHeader} contentContainerStyle={styles.messageList} showsVerticalScrollIndicator={false} ListEmptyComponent={ diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 789c6ff..5f4147d 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -93,6 +93,7 @@ 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, @@ -204,7 +205,9 @@ const SettingsScreen: React.FC = () => { const [availableVoices, setAvailableVoices] = useState>([]); // Datei-Manager const [fileManagerOpen, setFileManagerOpen] = useState(false); - const [fileManagerFiles, setFileManagerFiles] = useState>([]); + const [fileManagerFiles, setFileManagerFiles] = useState>([]); + const [fileFilterProjectId, setFileFilterProjectId] = useState('__all__'); + const [fileFilterProjects, setFileFilterProjects] = useState>([]); const [fileManagerLoading, setFileManagerLoading] = useState(false); const [fileManagerError, setFileManagerError] = useState(''); const [fileManagerSearch, setFileManagerSearch] = useState(''); @@ -726,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(() => { @@ -962,6 +979,29 @@ const SettingsScreen: React.FC = () => { ))} + {/* Projekt-Filter: scrollbare Pill-Reihe. „Alle Projekte" + „Hauptchat" + + ein Pill pro Projekt. Default = aktives Projekt (siehe useEffect oben). */} + + {[ + { id: '__all__', name: '📁 Alle Projekte' }, + { id: '', name: '💬 Hauptchat' }, + ...fileFilterProjects, + ].map(p => ( + setFileFilterProjectId(p.id)} + style={{ + paddingVertical:6, paddingHorizontal:12, borderRadius:14, + backgroundColor: fileFilterProjectId === p.id ? '#34C759' : '#1E1E2E', + }} + > + + {p.name} + + + ))} + {fileManagerLoading ? ( Lade... @@ -972,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)); diff --git a/aria-brain/main.py b/aria-brain/main.py index ec3f980..620c795 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -614,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) @@ -637,6 +642,7 @@ def chat(body: ChatIn, background: BackgroundTasks): turns=len(a.conversation.turns), distilling=needs_distill, events=a.pop_events(), + project_id=projects_mod.get_active(), ) diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index c738f7b..c8d71e8 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -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 []: @@ -1586,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", "") @@ -2125,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). diff --git a/diagnostic/index.html b/diagnostic/index.html index 83a703f..9ef8fa6 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -1109,6 +1109,11 @@ +
@@ -4209,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); @@ -4221,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; } diff --git a/diagnostic/server.js b/diagnostic/server.js index 0885759..8e963ce 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -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)