Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d430fa113e | |||
| 1fb512c2fd |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10904
|
||||
versionName "0.1.9.4"
|
||||
versionCode 10905
|
||||
versionName "0.1.9.5"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.9.4",
|
||||
"version": "0.1.9.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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();
|
||||
|
||||
@@ -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<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: ''});
|
||||
@@ -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<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();
|
||||
@@ -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<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
|
||||
@@ -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 = (
|
||||
<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.
|
||||
@@ -2483,9 +2617,8 @@ const ChatScreen: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setProjectsVisible(true)}
|
||||
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 12, paddingVertical: 6,
|
||||
@@ -2494,13 +2627,26 @@ const ChatScreen: React.FC = () => {
|
||||
borderColor: activeProject ? '#34C759' : '#1E1E2E',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
|
||||
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 11, color: '#555570' }}>
|
||||
{activeProject ? 'wechseln ›' : 'Projekte ›'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<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
|
||||
@@ -2591,7 +2737,7 @@ const ChatScreen: React.FC = () => {
|
||||
}, 300);
|
||||
}}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderMessage}
|
||||
renderItem={renderMessageWithProjectHeader}
|
||||
contentContainerStyle={styles.messageList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
|
||||
@@ -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<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('');
|
||||
@@ -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 = () => {
|
||||
</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>
|
||||
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+44
-1
@@ -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).
|
||||
|
||||
@@ -1109,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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user