Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fb08b4ea5 | |||
| d49ec64e27 | |||
| 882f3def99 | |||
| 092f085254 | |||
| 21eac63723 | |||
| 06316da36f | |||
| 7927ad05ae | |||
| 5b2c552a88 | |||
| f51ad1547d |
@@ -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 10906
|
versionCode 10909
|
||||||
versionName "0.1.9.6"
|
versionName "0.1.9.9"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.9.6",
|
"version": "0.1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ interface Props {
|
|||||||
/** Optional — wenn als Modal genutzt, sonst inline */
|
/** Optional — wenn als Modal genutzt, sonst inline */
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/** Wird gerufen wenn sich das aktive Projekt aendert — ChatScreen
|
/** Wird gerufen wenn Stefan ein anderes Projekt fokussiert (App-lokale
|
||||||
* refresht dann seinen Banner-State. */
|
* UI-Entscheidung, wechselt den Chat-Focus). */
|
||||||
onActiveChanged?: (project: Project | null) => void;
|
onActiveChanged?: (project: Project | null) => void;
|
||||||
|
/** Queue-Status pro Kontext (key "__main__" = Hauptchat, sonst project_id).
|
||||||
|
* Wenn geliefert: Status-Dot pro Zeile gerendert. */
|
||||||
|
queueStatus?: Record<string, { busy: boolean; queue_size: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fmtRel(unixSec: number): string {
|
function _fmtRel(unixSec: number): string {
|
||||||
@@ -48,7 +51,14 @@ function _fmtRel(unixSec: number): string {
|
|||||||
return new Date(unixSec * 1000).toLocaleDateString('de-DE');
|
return new Date(unixSec * 1000).toLocaleDateString('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged }) => {
|
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged, queueStatus }) => {
|
||||||
|
const _statusDot = (pid: string) => {
|
||||||
|
const s = queueStatus?.[pid];
|
||||||
|
if (!s) return { color: '#555570', label: '' };
|
||||||
|
if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' };
|
||||||
|
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
|
||||||
|
return { color: '#34C759', label: 'idle' };
|
||||||
|
};
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string>('');
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -88,13 +98,14 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
}, [visible, load]);
|
}, [visible, load]);
|
||||||
|
|
||||||
const switchTo = useCallback((id: string) => {
|
const switchTo = useCallback((id: string) => {
|
||||||
brainApi.switchProject(id)
|
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
|
||||||
.then(status => {
|
// Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr).
|
||||||
setActiveId(status.active_id || '');
|
// Wir suchen das Projekt lokal aus der Liste, damit die App den Namen kennt.
|
||||||
onActiveChangedRef.current?.(status.active);
|
setActiveId(id);
|
||||||
})
|
const p = id ? (projects.find(x => x.id === id) || null) : null;
|
||||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
onActiveChangedRef.current?.(p);
|
||||||
}, []);
|
if (onClose) onClose();
|
||||||
|
}, [projects, onClose]);
|
||||||
|
|
||||||
const createProject = useCallback(() => {
|
const createProject = useCallback(() => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
@@ -152,6 +163,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
|
|
||||||
const renderItem = ({ item }: { item: Project }) => {
|
const renderItem = ({ item }: { item: Project }) => {
|
||||||
const isActive = item.id === activeId;
|
const isActive = item.id === activeId;
|
||||||
|
const dot = _statusDot(item.id);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => switchTo(item.id)}
|
onPress={() => switchTo(item.id)}
|
||||||
@@ -160,15 +172,19 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
>
|
>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
{queueStatus && (
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||||
|
)}
|
||||||
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
||||||
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
||||||
{isActive && <Text style={s.activeBadge}>✓ AKTIV</Text>}
|
{isActive && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||||||
</View>
|
</View>
|
||||||
{item.description ? (
|
{item.description ? (
|
||||||
<Text style={s.rowDesc} numberOfLines={2}>{item.description}</Text>
|
<Text style={s.rowDesc} numberOfLines={2}>{item.description}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Text style={s.rowMeta}>
|
<Text style={s.rowMeta}>
|
||||||
{item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)}
|
{item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)}
|
||||||
|
{dot.label ? ` · ${dot.label}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -191,18 +207,29 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Hauptchat-Eintrag (immer oben) */}
|
{/* Hauptchat-Eintrag (immer oben) */}
|
||||||
<TouchableOpacity
|
{(() => {
|
||||||
onPress={() => switchTo('')}
|
const dot = _statusDot('__main__');
|
||||||
style={[s.row, !activeId && s.rowActive]}
|
return (
|
||||||
>
|
<TouchableOpacity
|
||||||
<View style={{ flex: 1 }}>
|
onPress={() => switchTo('')}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
style={[s.row, !activeId && s.rowActive]}
|
||||||
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
|
>
|
||||||
{!activeId && <Text style={s.activeBadge}>✓ AKTIV</Text>}
|
<View style={{ flex: 1 }}>
|
||||||
</View>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
<Text style={s.rowMeta}>Standard-Verlauf, keine Projekt-Zuordnung</Text>
|
{queueStatus && (
|
||||||
</View>
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||||
</TouchableOpacity>
|
)}
|
||||||
|
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
|
||||||
|
{!activeId && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||||||
|
</View>
|
||||||
|
<Text style={s.rowMeta}>
|
||||||
|
Standard-Verlauf, keine Projekt-Zuordnung
|
||||||
|
{dot.label ? ` · ${dot.label}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View style={{ padding: 24, alignItems: 'center' }}>
|
<View style={{ padding: 24, alignItems: 'center' }}>
|
||||||
|
|||||||
+130
-165
@@ -286,9 +286,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
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);
|
// Focus-One-View: welchen Chat sieht Stefan gerade?
|
||||||
// Lookup-Map id → Projekt — fuer Header-Names im Chat-Verlauf
|
// Leer = Hauptchat, sonst die project_id. Multi-Threading:
|
||||||
|
// Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
|
||||||
|
// die laufen im Brain weiter, wir sehen sie hier nur nicht.
|
||||||
|
const [focusedProjectId, setFocusedProjectId] = useState<string>('');
|
||||||
|
// Lookup-Map id → Projekt (fuer Drawer + Referenzen)
|
||||||
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
|
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
|
||||||
|
// Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
|
||||||
|
const [queueStatus, setQueueStatus] = useState<Record<string, { busy: boolean; queue_size: number }>>({});
|
||||||
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: ''});
|
||||||
@@ -465,27 +471,59 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, [dispatchWithAck]);
|
}, [dispatchWithAck]);
|
||||||
|
|
||||||
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
||||||
// sofort greift, ohne Context- oder Event-System)
|
// Projekt-Namen laden (Lookup-Map) + focusedProjectId aus AsyncStorage
|
||||||
// Aktives Projekt initial laden + bei RVS-Reconnect refreshen.
|
// wiederherstellen (Default = Hauptchat wenn nichts gespeichert). Der
|
||||||
// Wird zusaetzlich nach jedem chat-Response refreshed (siehe handleAriaMessage).
|
// Brain hat mit Multi-Threading keinen global-aktiven Projekt-State mehr;
|
||||||
|
// Focus ist reine App-lokale UI-Info.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProject = () => {
|
const loadNames = () => {
|
||||||
brainApi.getProjectStatus()
|
brainApi.listProjects(true)
|
||||||
.then(s => {
|
.then(list => {
|
||||||
setActiveProject(s.active || null);
|
|
||||||
// Lookup-Map fuellen damit der Chat-Verlauf Header mit Namen rendern kann
|
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const p of (s.projects || [])) map[p.id] = p.name;
|
for (const p of list) map[p.id] = p.name;
|
||||||
if (s.active) map[s.active.id] = s.active.name;
|
|
||||||
setProjectNameById(prev => ({ ...prev, ...map }));
|
setProjectNameById(prev => ({ ...prev, ...map }));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
loadProject();
|
loadNames();
|
||||||
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadProject(); });
|
// Letzten Focus aus Storage restoren
|
||||||
|
AsyncStorage.getItem('aria_focused_project_id').then(v => {
|
||||||
|
if (v && typeof v === 'string') setFocusedProjectId(v);
|
||||||
|
}).catch(() => {});
|
||||||
|
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadNames(); });
|
||||||
return () => unsub();
|
return () => unsub();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder
|
||||||
|
// da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
|
||||||
|
// fuer den Auto-Fall angenehm.
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
|
||||||
|
focusedProjectIdRef.current = focusedProjectId;
|
||||||
|
}, [focusedProjectId]);
|
||||||
|
|
||||||
|
// Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
|
||||||
|
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
|
||||||
|
// die sich bei jedem Wechsel).
|
||||||
|
const focusedProjectIdRef = useRef<string>('');
|
||||||
|
|
||||||
|
// Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und
|
||||||
|
// fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
if (rvs.getState() !== 'connected') return;
|
||||||
|
try {
|
||||||
|
const s = await brainApi.getProjectQueueStatus();
|
||||||
|
if (cancelled) return;
|
||||||
|
setQueueStatus(s.contexts || {});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
const iv = setInterval(poll, 2000);
|
||||||
|
return () => { cancelled = true; clearInterval(iv); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
||||||
@@ -769,6 +807,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
timestamp: m.ts || Date.now(),
|
timestamp: m.ts || Date.now(),
|
||||||
attachments: attachments.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
|
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
|
||||||
|
projectId: typeof m.project_id === 'string' ? m.project_id : '',
|
||||||
...(cmid && { clientMsgId: cmid }),
|
...(cmid && { clientMsgId: cmid }),
|
||||||
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
|
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
|
||||||
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
|
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
|
||||||
@@ -824,12 +863,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// project_changed: ARIA hat in einem Tool-Call ein Projekt erstellt /
|
// project_changed: ARIA hat via Tool ein Projekt erstellt/betreten/exited/beendet.
|
||||||
// betreten / verlassen / beendet. Banner refreshen.
|
// App entscheidet ob sie den Focus wechselt basierend auf action + payload.
|
||||||
if (message.type === 'project_changed') {
|
if (message.type === 'project_changed') {
|
||||||
brainApi.getProjectStatus()
|
const p: any = message.payload || {};
|
||||||
.then(s => setActiveProject(s.active || null))
|
const action = p.action || '';
|
||||||
.catch(() => {});
|
// Neuer Projekt-Name in Lookup-Map merken
|
||||||
|
if (p.id && p.name) {
|
||||||
|
setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
|
||||||
|
}
|
||||||
|
if (action === 'entered' || action === 'created') {
|
||||||
|
if (p.id) setFocusedProjectId(p.id);
|
||||||
|
} else if (action === 'exited') {
|
||||||
|
setFocusedProjectId('');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1373,6 +1420,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: windowMs,
|
||||||
endpointMs: 1500,
|
endpointMs: 1500,
|
||||||
hardCapMs: 60000,
|
hardCapMs: 60000,
|
||||||
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
|
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -1468,6 +1516,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: windowMs,
|
||||||
endpointMs: 1500,
|
endpointMs: 1500,
|
||||||
hardCapMs: 60000,
|
hardCapMs: 60000,
|
||||||
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
if (ok) {
|
if (ok) {
|
||||||
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
|
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
|
||||||
@@ -1524,6 +1573,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
||||||
endpointMs: 1500,
|
endpointMs: 1500,
|
||||||
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
||||||
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
console.warn('[Chat] passive streaming start failed — exit passive listening');
|
console.warn('[Chat] passive streaming start failed — exit passive listening');
|
||||||
@@ -1628,84 +1678,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Projekt-Bloecke: aufeinanderfolgende Nachrichten mit gleicher projectId
|
// Focus-One-View (Multi-Threading, 06/2026): Chat zeigt NUR die Nachrichten
|
||||||
// werden visuell gruppiert. Erstes Message-Item einer Gruppe bekommt einen
|
// des gerade fokussierten Kontexts. Hauptchat (focusedProjectId leer) →
|
||||||
// Header darueber (tappable für collapse). Collapsed → restliche Messages
|
// alle ungeтагtgeд Nachrichten. Projekt X aktiv → nur Nachrichten mit
|
||||||
// der Gruppe werden ausgefiltert; nur Header bleibt sichtbar.
|
// projectId === X. ARIA arbeitet weiterhin in allen Kontexten parallel;
|
||||||
// Hauptchat-Nachrichten (projectId leer) bleiben ungruppiert dazwischen.
|
// wir sehen nur den einen.
|
||||||
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(() => {
|
const messagesForRender = useMemo(() => {
|
||||||
return chatVisibleMessages.filter(m => {
|
return chatVisibleMessages.filter(m => (m.projectId || '') === focusedProjectId);
|
||||||
const pid = m.projectId || '';
|
}, [chatVisibleMessages, focusedProjectId]);
|
||||||
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]);
|
const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]);
|
||||||
|
|
||||||
@@ -1909,7 +1889,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
const cmid = nextClientMsgId();
|
const cmid = nextClientMsgId();
|
||||||
const activePid = activeProject?.id || '';
|
const activePid = focusedProjectIdRef.current;
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
@@ -1992,6 +1972,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
noSpeechTimeoutMs: 0,
|
noSpeechTimeoutMs: 0,
|
||||||
endpointMs: 1500,
|
endpointMs: 1500,
|
||||||
hardCapMs: 300000,
|
hardCapMs: 300000,
|
||||||
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
||||||
@@ -2428,44 +2409,6 @@ 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.
|
||||||
@@ -2618,41 +2561,63 @@ const ChatScreen: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
|
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
|
||||||
<View
|
{/* Focus-Indicator + Drawer-Toggle. Multi-Threading: das ist reine
|
||||||
style={{
|
Anzeige „was sehe ich gerade" — ARIA arbeitet gleichzeitig in
|
||||||
flexDirection: 'row', alignItems: 'center',
|
allen Kontexten weiter, wir zeigen hier nur einen. */}
|
||||||
paddingHorizontal: 12, paddingVertical: 6,
|
{(() => {
|
||||||
backgroundColor: activeProject ? 'rgba(52,199,89,0.10)' : 'transparent',
|
const isMain = !focusedProjectId;
|
||||||
borderBottomWidth: activeProject ? 2 : 1,
|
const focusedName = isMain ? '' : (projectNameById[focusedProjectId] || focusedProjectId);
|
||||||
borderColor: activeProject ? '#34C759' : '#1E1E2E',
|
const focusedQueue = queueStatus[isMain ? '__main__' : focusedProjectId];
|
||||||
}}
|
const dot = focusedQueue?.busy
|
||||||
>
|
? { color: '#FF6E6E', label: 'arbeitet' }
|
||||||
<TouchableOpacity onPress={() => setProjectsVisible(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
|
: focusedQueue?.queue_size
|
||||||
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
|
? { color: '#FFD60A', label: `Queue: ${focusedQueue.queue_size}` }
|
||||||
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
|
: { color: '#34C759', label: 'idle' };
|
||||||
</Text>
|
// Anzahl anderer Kontexte die gerade aktiv sind (fuer Drawer-Badge)
|
||||||
<Text style={{ fontSize: 11, color: '#555570', marginRight: 8 }}>
|
const otherActive = Object.entries(queueStatus).filter(([k, v]) => {
|
||||||
{activeProject ? 'wechseln ›' : 'Projekte ›'}
|
const kFocus = isMain ? '__main__' : focusedProjectId;
|
||||||
</Text>
|
if (k === kFocus) return false;
|
||||||
</TouchableOpacity>
|
return v.busy || v.queue_size > 0;
|
||||||
{activeProject && (
|
}).length;
|
||||||
<TouchableOpacity
|
return (
|
||||||
onPress={() => {
|
<View
|
||||||
brainApi.switchProject('').then(s => setActiveProject(s.active || null)).catch(() => {});
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 12, paddingVertical: 8,
|
||||||
|
backgroundColor: isMain ? '#1A1A26' : 'rgba(52,199,89,0.10)',
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderColor: isMain ? '#1E1E2E' : '#34C759',
|
||||||
}}
|
}}
|
||||||
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
|
||||||
</TouchableOpacity>
|
onPress={() => setProjectsVisible(true)}
|
||||||
)}
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}
|
||||||
</View>
|
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22, color: '#E0E0F0', fontWeight: '700' }}>☰</Text>
|
||||||
|
{otherActive > 0 && (
|
||||||
|
<View style={{ backgroundColor: '#FF6E6E', borderRadius: 8, minWidth: 16, height: 16, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{otherActive}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{ flex: 1, marginLeft: 10, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text style={{ fontSize: 14, color: isMain ? '#E0E0F0' : '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
|
||||||
|
{isMain ? '💬 Hauptchat' : `📁 ${focusedName}`}
|
||||||
|
</Text>
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||||
|
<Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Projekt-Modal */}
|
{/* Projekt-Drawer als Modal */}
|
||||||
<ProjectsBrowser
|
<ProjectsBrowser
|
||||||
visible={projectsVisible}
|
visible={projectsVisible}
|
||||||
onClose={() => setProjectsVisible(false)}
|
onClose={() => setProjectsVisible(false)}
|
||||||
onActiveChanged={(p) => setActiveProject(p)}
|
onActiveChanged={(p) => setFocusedProjectId(p?.id || '')}
|
||||||
|
queueStatus={queueStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Suchleiste mit Treffer-Navigation */}
|
{/* Suchleiste mit Treffer-Navigation */}
|
||||||
@@ -2737,7 +2702,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}}
|
}}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={renderMessageWithProjectHeader}
|
renderItem={renderMessage}
|
||||||
contentContainerStyle={styles.messageList}
|
contentContainerStyle={styles.messageList}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
|
|||||||
@@ -735,11 +735,11 @@ const SettingsScreen: React.FC = () => {
|
|||||||
brainApi.listProjects(true)
|
brainApi.listProjects(true)
|
||||||
.then(list => setFileFilterProjects(list.map(p => ({ id: p.id, name: p.name }))))
|
.then(list => setFileFilterProjects(list.map(p => ({ id: p.id, name: p.name }))))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
// Default-Filter: aktives Projekt (falls vorhanden), sonst "alle".
|
// Default-Filter: fokussiertes Projekt aus AsyncStorage (falls Stefan
|
||||||
brainApi.getProjectStatus()
|
// grade in einem drin ist), sonst "alle". Multi-Threading: Focus ist
|
||||||
.then(s => {
|
// App-lokal, kein Brain-Query mehr.
|
||||||
if (s.active_id) setFileFilterProjectId(s.active_id);
|
AsyncStorage.getItem('aria_focused_project_id')
|
||||||
})
|
.then(pid => { if (pid) setFileFilterProjectId(pid); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [fileManagerOpen]);
|
}, [fileManagerOpen]);
|
||||||
|
|
||||||
|
|||||||
@@ -982,6 +982,10 @@ class AudioService {
|
|||||||
noSpeechTimeoutMs?: number;
|
noSpeechTimeoutMs?: number;
|
||||||
endpointMs?: number;
|
endpointMs?: number;
|
||||||
hardCapMs?: number;
|
hardCapMs?: number;
|
||||||
|
/** Focused projectId — Bridge nutzt das als Default fuer den Voice-Router.
|
||||||
|
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||||
|
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||||
|
projectId?: string;
|
||||||
}): Promise<{ requestId: string; ok: boolean }> {
|
}): Promise<{ requestId: string; ok: boolean }> {
|
||||||
if (this.recordingState !== 'idle') {
|
if (this.recordingState !== 'idle') {
|
||||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||||
@@ -1055,6 +1059,7 @@ class AudioService {
|
|||||||
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
|
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
|
||||||
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
|
projectId: opts.projectId || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||||
|
|||||||
@@ -169,6 +169,16 @@ export interface ProjectStatus {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
|
||||||
|
* Key "__main__" = Hauptchat, sonst project_id. */
|
||||||
|
export interface QueueContextStatus {
|
||||||
|
busy: boolean;
|
||||||
|
queue_size: number;
|
||||||
|
}
|
||||||
|
export interface ProjectQueueStatus {
|
||||||
|
contexts: Record<string, QueueContextStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -590,6 +600,13 @@ export const brainApi = {
|
|||||||
body: patch,
|
body: patch,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
|
||||||
|
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
|
||||||
|
* Wird fuer Status-Dots im Drawer periodisch gepollt. */
|
||||||
|
getProjectQueueStatus(): Promise<ProjectQueueStatus> {
|
||||||
|
return _send('/projects/queue-status');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default brainApi;
|
export default brainApi;
|
||||||
|
|||||||
+66
-29
@@ -836,10 +836,14 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "project_enter",
|
"name": "project_enter",
|
||||||
"description": (
|
"description": (
|
||||||
"Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — "
|
"Signalisiert der App/Diagnostic 'wechsel zu diesem Projekt'. Fuzzy-"
|
||||||
"'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt "
|
"Match auf Namen — 'Spotify' findet das Projekt 'Spotify-Setup'. "
|
||||||
"tagged jeder neue Turn die project_id. Bei sehr alten Projekten: "
|
"Der AKTUELLE Turn bleibt aber in seinem Chat-Kontext — wir haben "
|
||||||
"vorher project_summary aufrufen damit Du Stefan abholst."
|
"Multi-Threading, kein globales 'aktives Projekt' mehr. Wenn Stefan "
|
||||||
|
"im Hauptchat sagt 'lass uns in Spotify weiter machen': "
|
||||||
|
"project_enter aufrufen (App wechselt Ansicht), aber Deine Antwort "
|
||||||
|
"geht trotzdem im Hauptchat raus. Bei sehr alten Projekten vorher "
|
||||||
|
"project_summary aufrufen damit Du Stefan abholst."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -855,8 +859,10 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "project_exit",
|
"name": "project_exit",
|
||||||
"description": (
|
"description": (
|
||||||
"Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze "
|
"Signalisiert der App/Diagnostic 'wechsel zurueck zum Hauptchat'. "
|
||||||
"wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä."
|
"Nutze wenn Stefan sagt 'Projekt Ende' oder 'zurueck zum Hauptchat' "
|
||||||
|
"waehrend er visuell in einem Projekt ist. Der aktuelle Turn bleibt "
|
||||||
|
"in seinem Chat-Kontext — Multi-Threading."
|
||||||
),
|
),
|
||||||
"parameters": {"type": "object", "properties": {}},
|
"parameters": {"type": "object", "properties": {}},
|
||||||
},
|
},
|
||||||
@@ -1051,7 +1057,21 @@ class Agent:
|
|||||||
|
|
||||||
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
||||||
|
|
||||||
def chat(self, user_message: str, source: str = "") -> str:
|
def chat(self, user_message: str, source: str = "",
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
pending_queue: Optional[list[str]] = None) -> str:
|
||||||
|
"""Verarbeitet eine User-Nachricht — pro Request project_id explizit
|
||||||
|
angegeben (leer = Hauptchat). Kein globaler active_project-State mehr —
|
||||||
|
so laufen parallele /chat-Requests fuer verschiedene Projekte echt
|
||||||
|
parallel (Multi-Threading-Architektur seit 06/2026).
|
||||||
|
|
||||||
|
pending_queue: Liste weiterer User-Nachrichten die in DIESEM Projekt
|
||||||
|
NACH dem aktuellen Turn warten. ARIA sieht sie im System-Prompt und
|
||||||
|
soll pruefen ob eine spaetere Nachricht den aktuellen Task
|
||||||
|
korrigiert / annuliert (dann Skip-Antwort statt Ausfuehren).
|
||||||
|
|
||||||
|
Wenn project_id=None (Backward-Compat fuer Aufrufer die den Param nicht
|
||||||
|
setzen): wird als Hauptchat behandelt."""
|
||||||
user_message = (user_message or "").strip()
|
user_message = (user_message or "").strip()
|
||||||
if not user_message:
|
if not user_message:
|
||||||
raise ValueError("Leere Nachricht")
|
raise ValueError("Leere Nachricht")
|
||||||
@@ -1059,9 +1079,8 @@ class Agent:
|
|||||||
# Events vom letzten Turn weglassen
|
# Events vom letzten Turn weglassen
|
||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
|
|
||||||
# Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der
|
# Projekt-Kontext pro Request statt aus globalem State
|
||||||
# neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt.
|
active_project_id = (project_id or "").strip()
|
||||||
active_project_id = projects_mod.get_active()
|
|
||||||
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
|
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
|
||||||
|
|
||||||
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
||||||
@@ -1127,6 +1146,28 @@ class Agent:
|
|||||||
oauth_callback_host=oauth_host,
|
oauth_callback_host=oauth_host,
|
||||||
oauth_callback_port=oauth_port,
|
oauth_callback_port=oauth_port,
|
||||||
oauth_callback_tls=oauth_tls)
|
oauth_callback_tls=oauth_tls)
|
||||||
|
# Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
|
||||||
|
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
|
||||||
|
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
|
||||||
|
if pending_queue:
|
||||||
|
queue_lines = "\n".join(f" - {m[:280]}" for m in pending_queue[:5])
|
||||||
|
more_hint = ""
|
||||||
|
if len(pending_queue) > 5:
|
||||||
|
more_hint = f"\n ... und {len(pending_queue) - 5} weitere"
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\n## QUEUE — NACH DIESEM TASK WARTEN\n"
|
||||||
|
f"{queue_lines}{more_hint}\n"
|
||||||
|
f"\nBEVOR DU DEN AKTUELLEN TASK LOESST:\n"
|
||||||
|
f" 1. Pruefe die Queue oben — widerspricht/annuliert eine der spaeteren "
|
||||||
|
f"Nachrichten den aktuellen Task?\n"
|
||||||
|
f" 2. Wenn ja: antworte ganz kurz 'Task ubersprungen — wird durch spaetere "
|
||||||
|
f"Nachricht korrigiert' und mach KEINE Aktion. Der spaetere Task laeuft dann "
|
||||||
|
f"ganz normal als naechste Anfrage durch.\n"
|
||||||
|
f" 3. Wenn nein / unabhaengige Ergaenzung: Task normal loesen.\n"
|
||||||
|
f"Beispiel: aktueller Task 'titelleiste rot', Queue enthaelt "
|
||||||
|
f"'doch nicht, mach sie blau' → skip, blau kommt als naechste Anfrage."
|
||||||
|
)
|
||||||
|
|
||||||
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
|
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
|
||||||
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
|
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
|
||||||
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
|
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
|
||||||
@@ -1217,19 +1258,17 @@ class Agent:
|
|||||||
err_text = f"[Fehler: {exc}]"
|
err_text = f"[Fehler: {exc}]"
|
||||||
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
|
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
|
||||||
try:
|
try:
|
||||||
# Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops
|
# Turn-Kontext bleibt gleich — es gibt keinen globalen Wechsel
|
||||||
# geaendert haben (project_enter/exit als Tool-Call).
|
# mehr, jeder Request laeuft in seinem eigenen project_id-Kontext.
|
||||||
self.conversation.add("assistant", err_text,
|
self.conversation.add("assistant", err_text,
|
||||||
project_id=projects_mod.get_active())
|
project_id=active_project_id)
|
||||||
except Exception as add_exc:
|
except Exception as add_exc:
|
||||||
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
|
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# 7. Assistant-Turn (final reply) in die Conversation
|
# 7. Assistant-Turn (final reply) in die Conversation
|
||||||
# NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der
|
|
||||||
# Final-Reply schon im neuen Projekt-Kontext.
|
|
||||||
self.conversation.add("assistant", final_reply,
|
self.conversation.add("assistant", final_reply,
|
||||||
project_id=projects_mod.get_active())
|
project_id=active_project_id)
|
||||||
return final_reply
|
return final_reply
|
||||||
|
|
||||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||||
@@ -1804,7 +1843,10 @@ class Agent:
|
|||||||
"project": p,
|
"project": p,
|
||||||
"action": "created",
|
"action": "created",
|
||||||
})
|
})
|
||||||
return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst."
|
return (f"OK — Projekt '{p['name']}' angelegt (id={p['id']}). App/Diagnostic "
|
||||||
|
f"kriegen ein project_changed-Event und koennen dahin wechseln. "
|
||||||
|
f"Kommender Turn bleibt aber im aktuellen Chat-Kontext — "
|
||||||
|
f"Multi-Threading, jeder Chat ist eigenstaendig.")
|
||||||
if name == "project_enter":
|
if name == "project_enter":
|
||||||
pname = (arguments.get("name") or "").strip()
|
pname = (arguments.get("name") or "").strip()
|
||||||
if not pname:
|
if not pname:
|
||||||
@@ -1812,7 +1854,6 @@ class Agent:
|
|||||||
p = projects_mod.find_project(pname)
|
p = projects_mod.find_project(pname)
|
||||||
if not p:
|
if not p:
|
||||||
return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
|
return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
|
||||||
projects_mod.set_active(p["id"])
|
|
||||||
self._pending_events.append({
|
self._pending_events.append({
|
||||||
"type": "project_changed",
|
"type": "project_changed",
|
||||||
"project": p,
|
"project": p,
|
||||||
@@ -1822,31 +1863,27 @@ class Agent:
|
|||||||
hint = ""
|
hint = ""
|
||||||
if turn_count > 0:
|
if turn_count > 0:
|
||||||
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
|
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
|
||||||
return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}"
|
return (f"OK — App/Diagnostic wird zum Projekt '{p['name']}' "
|
||||||
|
f"(id={p['id']}, {turn_count} bisherige Turns) umschalten. "
|
||||||
|
f"Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.{hint}")
|
||||||
if name == "project_exit":
|
if name == "project_exit":
|
||||||
active_id = projects_mod.get_active()
|
|
||||||
if not active_id:
|
|
||||||
return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread."
|
|
||||||
p = projects_mod.get_project(active_id)
|
|
||||||
projects_mod.set_active("")
|
|
||||||
self._pending_events.append({
|
self._pending_events.append({
|
||||||
"type": "project_changed",
|
"type": "project_changed",
|
||||||
"project": p,
|
"project": None,
|
||||||
"action": "exited",
|
"action": "exited",
|
||||||
})
|
})
|
||||||
return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread."
|
return ("OK — App/Diagnostic bekommt Signal 'zurueck zum Hauptchat'. "
|
||||||
|
"Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.")
|
||||||
if name == "project_list":
|
if name == "project_list":
|
||||||
items = projects_mod.list_projects()
|
items = projects_mod.list_projects()
|
||||||
if not items:
|
if not items:
|
||||||
return "(keine Projekte angelegt)"
|
return "(keine Projekte angelegt)"
|
||||||
active_id = projects_mod.get_active()
|
|
||||||
lines = []
|
lines = []
|
||||||
for p in items:
|
for p in items:
|
||||||
marker = " ← AKTIV" if p["id"] == active_id else ""
|
|
||||||
status_lbl = p.get("status", "active")
|
status_lbl = p.get("status", "active")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
|
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
|
||||||
f"status={status_lbl}){marker}"
|
f"status={status_lbl})"
|
||||||
)
|
)
|
||||||
return "Projekte:\n" + "\n".join(lines)
|
return "Projekte:\n" + "\n".join(lines)
|
||||||
if name == "project_summary":
|
if name == "project_summary":
|
||||||
|
|||||||
+119
-26
@@ -607,6 +607,11 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
|||||||
class ChatIn(BaseModel):
|
class ChatIn(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
source: str = "" # "app" / "diagnostic" / "stt" — optional
|
source: str = "" # "app" / "diagnostic" / "stt" — optional
|
||||||
|
# Multi-Threading: Client bestimmt pro Request welches Projekt (leer = Hauptchat).
|
||||||
|
# Kein globaler active_project-State mehr im Brain — parallele Requests fuer
|
||||||
|
# verschiedene Projekte laufen echt parallel, nur Requests fuers gleiche
|
||||||
|
# Projekt queuen (per-Projekt-Lock).
|
||||||
|
project_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ChatOut(BaseModel):
|
class ChatOut(BaseModel):
|
||||||
@@ -614,36 +619,124 @@ 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
|
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||||
# waehrend des Turns gewechselt haben). Bridge gibt das an die Chat-
|
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||||
# Bubble-Broadcasts weiter damit App + Diagnostic die Nachricht zum
|
# UI landet.
|
||||||
# richtigen Projekt-Block sortieren koennen.
|
|
||||||
project_id: str = ""
|
project_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chat", response_model=ChatOut)
|
# Per-Projekt async-Locks fuer Queue-Behavior: Requests fuers gleiche Projekt
|
||||||
def chat(body: ChatIn, background: BackgroundTasks):
|
# warten aufeinander (queue), Requests fuer verschiedene Projekte laufen echt
|
||||||
"""Hauptpfad. Antwort kommt synchron. Memory-Destillat laeuft
|
# parallel. Hauptchat = Lock unter key "" (leerer String).
|
||||||
im Hintergrund nachdem die Response rausging."""
|
_project_locks: dict[str, asyncio.Lock] = {}
|
||||||
a = agent()
|
_project_locks_meta_lock = asyncio.Lock()
|
||||||
try:
|
# Pro Projekt eine Liste noch-nicht-verarbeiteter Requests. Wird beim Enqueue
|
||||||
reply = a.chat(body.message, source=body.source)
|
# ergaenzt, beim Fertig-Werden gepoppt. Ermoeglicht Queue-Aware-Prompting:
|
||||||
except ValueError as exc:
|
# waehrend ARIA an Task N arbeitet, sieht sie N+1..N+k als System-Prompt-Hinweis
|
||||||
raise HTTPException(400, str(exc))
|
# und kann entscheiden ob eine spaetere Nachricht die aktuelle korrigiert/
|
||||||
except RuntimeError as exc:
|
# annuliert → dann Skip-Antwort statt Ausfuehren.
|
||||||
logger.error("chat fehlgeschlagen: %s", exc)
|
_project_pending: dict[str, list[dict]] = {}
|
||||||
raise HTTPException(502, str(exc))
|
|
||||||
|
|
||||||
needs_distill = a.conversation.needs_distill()
|
|
||||||
if needs_distill:
|
async def _get_project_lock(project_id: str) -> asyncio.Lock:
|
||||||
background.add_task(a.distill_old_turns)
|
"""Holt (oder erzeugt) den asyncio.Lock fuer ein bestimmtes Projekt.
|
||||||
return ChatOut(
|
Nutzt _project_locks_meta_lock zur Vermeidung von Race Conditions
|
||||||
reply=reply,
|
beim ersten-Zugriff pro Projekt."""
|
||||||
turns=len(a.conversation.turns),
|
async with _project_locks_meta_lock:
|
||||||
distilling=needs_distill,
|
lock = _project_locks.get(project_id)
|
||||||
events=a.pop_events(),
|
if lock is None:
|
||||||
project_id=projects_mod.get_active(),
|
lock = asyncio.Lock()
|
||||||
)
|
_project_locks[project_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _project_queue_snapshot() -> dict:
|
||||||
|
"""Snapshot fuer /projects/queue-status: welche Projekte arbeiten gerade,
|
||||||
|
wieviele wait-in-queue haben, welche sind idle."""
|
||||||
|
out = {}
|
||||||
|
# Zeige nur Kontexte mit Aktivitaet — locked oder pending
|
||||||
|
seen: set = set()
|
||||||
|
for pid, lock in _project_locks.items():
|
||||||
|
pending = len(_project_pending.get(pid, []))
|
||||||
|
is_busy = lock.locked()
|
||||||
|
# busy: gerade in Verarbeitung. queue: N weitere warten dahinter.
|
||||||
|
# Der Busy-Request zaehlt NICHT in queue (er ist ja aus pending schon "raus").
|
||||||
|
out[pid or "__main__"] = {
|
||||||
|
"busy": is_busy,
|
||||||
|
"queue_size": max(0, pending - (1 if is_busy else 0)),
|
||||||
|
}
|
||||||
|
seen.add(pid)
|
||||||
|
for pid, pend in _project_pending.items():
|
||||||
|
if pid in seen:
|
||||||
|
continue
|
||||||
|
out[pid or "__main__"] = {"busy": False, "queue_size": len(pend)}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chat", response_model=ChatOut)
|
||||||
|
async def chat(body: ChatIn, background: BackgroundTasks):
|
||||||
|
"""Hauptpfad. Antwort kommt synchron. Memory-Destillat laeuft
|
||||||
|
im Hintergrund nachdem die Response rausging.
|
||||||
|
|
||||||
|
Multi-Threading: Requests fuers gleiche Projekt (project_id gleich)
|
||||||
|
laufen serialisiert durch den per-Projekt-Lock — Queue-Behavior.
|
||||||
|
Verschiedene Projekte laufen parallel."""
|
||||||
|
pid = (body.project_id or "").strip()
|
||||||
|
lock = await _get_project_lock(pid)
|
||||||
|
# Vor dem Lock in die Pending-Liste, damit die verlaufende Task sehen kann
|
||||||
|
# was NACH ihr in der Warteschlange steht (Queue-Aware Prompting).
|
||||||
|
import uuid as _uuid
|
||||||
|
req_id = _uuid.uuid4().hex
|
||||||
|
_project_pending.setdefault(pid, []).append({
|
||||||
|
"id": req_id, "message": body.message, "source": body.source,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
async with lock:
|
||||||
|
# Snapshot: was liegt NACH mir in der Queue?
|
||||||
|
after_me = [
|
||||||
|
e["message"] for e in _project_pending.get(pid, [])
|
||||||
|
if e["id"] != req_id
|
||||||
|
]
|
||||||
|
a = agent()
|
||||||
|
try:
|
||||||
|
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
||||||
|
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
reply = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: a.chat(
|
||||||
|
body.message, source=body.source, project_id=pid,
|
||||||
|
pending_queue=after_me,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("chat fehlgeschlagen: %s", exc)
|
||||||
|
raise HTTPException(502, str(exc))
|
||||||
|
|
||||||
|
needs_distill = a.conversation.needs_distill()
|
||||||
|
if needs_distill:
|
||||||
|
background.add_task(a.distill_old_turns)
|
||||||
|
return ChatOut(
|
||||||
|
reply=reply,
|
||||||
|
turns=len(a.conversation.turns),
|
||||||
|
distilling=needs_distill,
|
||||||
|
events=a.pop_events(),
|
||||||
|
project_id=pid,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_project_pending[pid] = [
|
||||||
|
e for e in _project_pending.get(pid, []) if e["id"] != req_id
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/queue-status")
|
||||||
|
def projects_queue_status():
|
||||||
|
"""Snapshot: fuer jeden Projekt-Kontext (inkl. Hauptchat unter __main__)
|
||||||
|
- busy: True wenn gerade ein Request in Verarbeitung
|
||||||
|
- queue_size: wieviele weitere warten dahinter"""
|
||||||
|
return {"contexts": _project_queue_snapshot()}
|
||||||
|
|
||||||
|
|
||||||
# ── Projekte ────────────────────────────────────────────────────────
|
# ── Projekte ────────────────────────────────────────────────────────
|
||||||
|
|||||||
+193
-8
@@ -611,6 +611,18 @@ class ARIABridge:
|
|||||||
self._last_chat_final_at: float = 0.0
|
self._last_chat_final_at: float = 0.0
|
||||||
# requestId → messageId Map fuer XTTS-Audio-Cache (App-seitige Zuordnung)
|
# requestId → messageId Map fuer XTTS-Audio-Cache (App-seitige Zuordnung)
|
||||||
self._xtts_request_to_message: dict[str, str] = {}
|
self._xtts_request_to_message: dict[str, str] = {}
|
||||||
|
# Voice-Router (Multi-Threading, 06/2026): sticky Projekt-Kontext fuer
|
||||||
|
# STT-Voice-Nachrichten. Wechselt via „fuer <name>:"-Prefix, faellt nach
|
||||||
|
# STICKY_TIMEOUT_SEC ohne neue Voice-Message zurueck auf Hauptchat.
|
||||||
|
# Meta-Kommandos („zurueck zum hauptchat") werden client-seitig
|
||||||
|
# interceptiert und aendern hier den Sticky OHNE Brain-Roundtrip.
|
||||||
|
self._voice_sticky_project_id: str = ""
|
||||||
|
self._voice_sticky_expires_at: float = 0.0
|
||||||
|
# Focused-project pro Stream: die App schickt bei stt_stream_start
|
||||||
|
# die projectId ihres aktuellen Focus mit. Wenn das Voice-Ergebnis
|
||||||
|
# weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen,
|
||||||
|
# nutzen wir das als Default (Voice folgt dem sichtbaren Kontext).
|
||||||
|
self._stt_stream_projects: dict[str, str] = {}
|
||||||
# Voice-Override aus letzter Chat-Nachricht einer App.
|
# Voice-Override aus letzter Chat-Nachricht einer App.
|
||||||
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
||||||
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
||||||
@@ -1237,6 +1249,7 @@ class ARIABridge:
|
|||||||
"text": display_text,
|
"text": display_text,
|
||||||
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||||
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||||
|
"project_id": turn_pid,
|
||||||
})
|
})
|
||||||
|
|
||||||
metadata = payload.get("metadata", {})
|
metadata = payload.get("metadata", {})
|
||||||
@@ -1509,7 +1522,9 @@ class ARIABridge:
|
|||||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
|
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None:
|
async def send_to_core(self, text: str, source: str = "bridge",
|
||||||
|
client_msg_id: Optional[str] = None,
|
||||||
|
project_id: str = "") -> None:
|
||||||
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
|
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
|
||||||
|
|
||||||
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
|
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
|
||||||
@@ -1519,8 +1534,13 @@ class ARIABridge:
|
|||||||
"""
|
"""
|
||||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
url = f"{brain_url}/chat"
|
url = f"{brain_url}/chat"
|
||||||
payload = json.dumps({"message": text, "source": source}).encode("utf-8")
|
# project_id kommt jetzt IM /chat-Body an das Brain (Multi-Threading:
|
||||||
logger.info("[brain] chat ← %s '%s'", source, text[:80])
|
# per-Request-Routing statt globaler active_project-State).
|
||||||
|
payload = json.dumps({
|
||||||
|
"message": text, "source": source,
|
||||||
|
"project_id": project_id or "",
|
||||||
|
}).encode("utf-8")
|
||||||
|
logger.info("[brain] chat ← %s '%s' project=%s", source, text[:80], project_id or "(main)")
|
||||||
|
|
||||||
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
|
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
|
||||||
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
|
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
|
||||||
@@ -1529,6 +1549,8 @@ class ARIABridge:
|
|||||||
entry: dict = {"role": "user", "text": text, "source": source}
|
entry: dict = {"role": "user", "text": text, "source": source}
|
||||||
if client_msg_id:
|
if client_msg_id:
|
||||||
entry["clientMsgId"] = client_msg_id
|
entry["clientMsgId"] = client_msg_id
|
||||||
|
if project_id:
|
||||||
|
entry["project_id"] = project_id
|
||||||
self._append_chat_backup(entry)
|
self._append_chat_backup(entry)
|
||||||
|
|
||||||
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
||||||
@@ -1957,6 +1979,7 @@ class ARIABridge:
|
|||||||
core_text,
|
core_text,
|
||||||
source="app" + (" [barge-in]" if interrupted else ""),
|
source="app" + (" [barge-in]" if interrupted else ""),
|
||||||
client_msg_id=client_msg_id,
|
client_msg_id=client_msg_id,
|
||||||
|
project_id=str(payload.get("projectId") or ""),
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2770,6 +2793,25 @@ class ARIABridge:
|
|||||||
future.set_result(text)
|
future.set_result(text)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif msg_type == "stt_stream_start":
|
||||||
|
# App startet eine neue Streaming-STT-Session. Wir merken uns
|
||||||
|
# ihre Focus-projectId damit der Voice-Router beim spaeteren
|
||||||
|
# stt_endpoint einen sinnvollen Default hat (Voice folgt dem
|
||||||
|
# visuellen Focus).
|
||||||
|
req_id = payload.get("requestId", "") or ""
|
||||||
|
focused_pid = str(payload.get("projectId") or "")
|
||||||
|
if req_id:
|
||||||
|
self._stt_stream_projects[req_id] = focused_pid
|
||||||
|
logger.info("[rvs] stt_stream_start id=%s focus=%s",
|
||||||
|
req_id[:12], focused_pid or "(main)")
|
||||||
|
return
|
||||||
|
|
||||||
|
elif msg_type == "stt_stream_end":
|
||||||
|
# Session vorbei — Focus-Tracking fuer diese requestId aufraeumen.
|
||||||
|
req_id = payload.get("requestId", "") or ""
|
||||||
|
self._stt_stream_projects.pop(req_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type == "stt_endpoint":
|
elif msg_type == "stt_endpoint":
|
||||||
# Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus
|
# Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus
|
||||||
# einen Endpoint erkannt und schickt den finalen Text direkt.
|
# einen Endpoint erkannt und schickt den finalen Text direkt.
|
||||||
@@ -2818,9 +2860,15 @@ class ARIABridge:
|
|||||||
if self._is_duplicate_client_msg(client_msg_id):
|
if self._is_duplicate_client_msg(client_msg_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# App-Focus aus stt_stream_start-Registry auflösen (falls die
|
||||||
|
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
||||||
|
stream_req_id = payload.get("requestId", "") or ""
|
||||||
|
focused_pid = self._stt_stream_projects.pop(stream_req_id, "")
|
||||||
|
|
||||||
asyncio.create_task(self._process_endpoint_text(
|
asyncio.create_task(self._process_endpoint_text(
|
||||||
text, interrupted, audio_request_id, location,
|
text, interrupted, audio_request_id, location,
|
||||||
client_msg_id=client_msg_id))
|
client_msg_id=client_msg_id,
|
||||||
|
focused_project_id=focused_pid))
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "oauth_callback":
|
elif msg_type == "oauth_callback":
|
||||||
@@ -2965,27 +3013,163 @@ class ARIABridge:
|
|||||||
else:
|
else:
|
||||||
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
||||||
|
|
||||||
|
# Voice-Router-Konstanten
|
||||||
|
_VOICE_STICKY_TIMEOUT_SEC = 30.0
|
||||||
|
_VOICE_META_BACK_TO_MAIN = re.compile(
|
||||||
|
r"^\s*(?:aria[,.]?\s+)?"
|
||||||
|
r"(?:"
|
||||||
|
# „zurück zum hauptchat / hauptmenü / haupt / menü / main"
|
||||||
|
r"zur(?:ü|ue)ck\s+(?:zum|zur|ins?|in\s+den)\s+"
|
||||||
|
r"(?:hauptchat|hauptmen(?:ü|ue)|haupt|men(?:ü|ue)|main)"
|
||||||
|
r"|"
|
||||||
|
# „zurück hauptchat / zurück haupt"
|
||||||
|
r"zur(?:ü|ue)ck\s+(?:hauptchat|hauptmen(?:ü|ue)|haupt|main)"
|
||||||
|
r"|"
|
||||||
|
# „hauptchat bitte", „aria hauptchat" (auch mit Menü/Main)
|
||||||
|
r"(?:hauptchat|hauptmen(?:ü|ue)|main)\s+bitte"
|
||||||
|
r"|"
|
||||||
|
r"aria[,.]?\s+(?:hauptchat|hauptmen(?:ü|ue)|haupt|main)"
|
||||||
|
r")\s*[.!?]?\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_VOICE_META_PROJECT_PREFIX = re.compile(
|
||||||
|
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_voice_router(self, text: str,
|
||||||
|
default_project_id: str = "") -> tuple[bool, str, str, str]:
|
||||||
|
"""Voice-Router: entscheidet ob ein STT-Text ans Brain geht und wenn ja
|
||||||
|
an welchen Projekt-Kontext.
|
||||||
|
|
||||||
|
Returns (should_forward, cleaned_text, project_id, meta_action):
|
||||||
|
- should_forward=False: reines Meta-Kommando, kein Brain-Call.
|
||||||
|
meta_action beschreibt was passiert ist (broadcastet an UI).
|
||||||
|
- should_forward=True: cleaned_text ans Brain, project_id ist Focus.
|
||||||
|
Bei Prefix wird der Prefix aus dem Text entfernt.
|
||||||
|
|
||||||
|
Prioritaets-Reihenfolge:
|
||||||
|
1. Meta „zurueck zum hauptchat" → Sticky reset, kein Forward.
|
||||||
|
2. „fuer <name>:"-Prefix → Sticky auf gematchtes Projekt.
|
||||||
|
3. Sticky aktiv (<=30s alt) → dessen Projekt.
|
||||||
|
4. default_project_id (App-Focus) — Voice folgt dem sichtbaren
|
||||||
|
Kontext. Wenn App in Projekt X guckt, geht die STT-Nachricht
|
||||||
|
ohne weitere Marker dort rein.
|
||||||
|
5. Fallback: Hauptchat.
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
now = _time.time()
|
||||||
|
stripped = text.strip()
|
||||||
|
|
||||||
|
# 1) Meta: zurueck zum Hauptchat
|
||||||
|
if self._VOICE_META_BACK_TO_MAIN.match(stripped):
|
||||||
|
self._voice_sticky_project_id = ""
|
||||||
|
self._voice_sticky_expires_at = 0.0
|
||||||
|
return (False, "", "", "back_to_main")
|
||||||
|
|
||||||
|
# 2) Prefix: "fuer <name>: <text>"
|
||||||
|
m = self._VOICE_META_PROJECT_PREFIX.match(stripped)
|
||||||
|
if m:
|
||||||
|
name = m.group(1)
|
||||||
|
remainder = m.group(2).strip()
|
||||||
|
# Fuzzy-Match auf Projekt via Brain-API
|
||||||
|
try:
|
||||||
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
|
with urllib.request.urlopen(f"{brain_url}/projects/list", timeout=3) as r:
|
||||||
|
projects = json.loads(r.read()).get("projects", [])
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
best, best_score = None, 0.0
|
||||||
|
q = name.lower()
|
||||||
|
for p in projects:
|
||||||
|
pname = p.get("name", "").lower()
|
||||||
|
if q == pname or q == p.get("id", ""):
|
||||||
|
best, best_score = p, 1.0
|
||||||
|
break
|
||||||
|
s = SequenceMatcher(None, q, pname).ratio()
|
||||||
|
if s > best_score:
|
||||||
|
best, best_score = p, s
|
||||||
|
if best and best_score >= 0.6:
|
||||||
|
pid = best["id"]
|
||||||
|
self._voice_sticky_project_id = pid
|
||||||
|
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||||
|
logger.info("[voice-router] Prefix → Projekt '%s' (id=%s, score=%.2f)",
|
||||||
|
best.get("name"), pid, best_score)
|
||||||
|
return (True, remainder or stripped, pid, "project_prefix")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[voice-router] Prefix-Match fehlgeschlagen: %s", exc)
|
||||||
|
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
||||||
|
# 3) Kein Meta / Prefix → Sticky oder Default
|
||||||
|
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
||||||
|
# Sticky refreshen
|
||||||
|
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||||
|
return (True, stripped, self._voice_sticky_project_id, "sticky")
|
||||||
|
# Sticky abgelaufen — zurücksetzen
|
||||||
|
self._voice_sticky_project_id = ""
|
||||||
|
# 4) App-Focus als Default: Voice folgt dem sichtbaren Kontext
|
||||||
|
if default_project_id:
|
||||||
|
return (True, stripped, default_project_id, "app_focus")
|
||||||
|
# 5) Fallback Hauptchat
|
||||||
|
return (True, stripped, "", "default")
|
||||||
|
|
||||||
async def _process_endpoint_text(self, text: str,
|
async def _process_endpoint_text(self, text: str,
|
||||||
interrupted: bool = False,
|
interrupted: bool = False,
|
||||||
audio_request_id: str = "",
|
audio_request_id: str = "",
|
||||||
location: Optional[dict] = None,
|
location: Optional[dict] = None,
|
||||||
client_msg_id: Optional[str] = None) -> None:
|
client_msg_id: Optional[str] = None,
|
||||||
|
focused_project_id: str = "") -> None:
|
||||||
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
||||||
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
||||||
|
|
||||||
Spiegel-Methode zu _process_app_audio NACH dem STT-Schritt. Bewusst
|
Spiegel-Methode zu _process_app_audio NACH dem STT-Schritt. Bewusst
|
||||||
eigene Methode statt Code-Pfade in _process_app_audio aufdroeseln,
|
eigene Methode statt Code-Pfade in _process_app_audio aufdroeseln,
|
||||||
damit der Legacy-Pfad (App schickt 'audio') unangetastet bleibt.
|
damit der Legacy-Pfad (App schickt 'audio') unangetastet bleibt.
|
||||||
|
|
||||||
|
Voice-Router: interceptiert Meta-Kommandos (zurueck zum Hauptchat)
|
||||||
|
+ Prefix-Adressierung („fuer Frankreich: ...") + 30s-Sticky. Meta
|
||||||
|
selbst geht NICHT ans Brain, sondern broadcastet als project_changed-
|
||||||
|
Event → App+Diagnostic wechseln den Focus.
|
||||||
"""
|
"""
|
||||||
|
should_forward, cleaned, project_id, meta_action = self._apply_voice_router(
|
||||||
|
text, default_project_id=focused_project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if meta_action in ("back_to_main", "project_prefix"):
|
||||||
|
# UI-Focus-Update broadcasten
|
||||||
|
payload = {"action": "entered" if meta_action == "project_prefix" else "exited"}
|
||||||
|
if meta_action == "project_prefix" and project_id:
|
||||||
|
# Namen aus dem Cache holen — best effort
|
||||||
|
try:
|
||||||
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
|
with urllib.request.urlopen(f"{brain_url}/projects/list", timeout=2) as r:
|
||||||
|
for p in json.loads(r.read()).get("projects", []):
|
||||||
|
if p.get("id") == project_id:
|
||||||
|
payload["id"] = project_id
|
||||||
|
payload["name"] = p.get("name", "")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
payload["id"] = project_id
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "project_changed",
|
||||||
|
"payload": payload,
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not should_forward:
|
||||||
|
logger.info("[voice-router] Meta-Kommando '%s' intercepted, kein Brain-Call",
|
||||||
|
meta_action)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stt_payload = {
|
stt_payload = {
|
||||||
"text": text,
|
"text": cleaned,
|
||||||
"sender": "stt",
|
"sender": "stt",
|
||||||
}
|
}
|
||||||
if audio_request_id:
|
if audio_request_id:
|
||||||
stt_payload["audioRequestId"] = audio_request_id
|
stt_payload["audioRequestId"] = audio_request_id
|
||||||
if location:
|
if location:
|
||||||
stt_payload["location"] = location
|
stt_payload["location"] = location
|
||||||
|
if project_id:
|
||||||
|
stt_payload["projectId"] = project_id
|
||||||
ok = await self._send_to_rvs({
|
ok = await self._send_to_rvs({
|
||||||
"type": "chat",
|
"type": "chat",
|
||||||
"payload": stt_payload,
|
"payload": stt_payload,
|
||||||
@@ -2998,10 +3182,11 @@ class ARIABridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
||||||
|
|
||||||
core_text = self._build_core_text(text, interrupted, location)
|
core_text = self._build_core_text(cleaned, interrupted, location)
|
||||||
await self.send_to_core(core_text,
|
await self.send_to_core(core_text,
|
||||||
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
||||||
client_msg_id=client_msg_id)
|
client_msg_id=client_msg_id,
|
||||||
|
project_id=project_id)
|
||||||
|
|
||||||
async def _stt_remote(self, audio_b64: str, mime_type: str) -> Optional[str]:
|
async def _stt_remote(self, audio_b64: str, mime_type: str) -> Optional[str]:
|
||||||
"""Schickt Audio an die whisper-bridge und wartet auf stt_response.
|
"""Schickt Audio an die whisper-bridge und wartet auf stt_response.
|
||||||
|
|||||||
+105
-2
@@ -305,6 +305,12 @@
|
|||||||
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Multi-Threading: Kontext-Strip ueber dem Chat. Jeder Kontext
|
||||||
|
(Hauptchat + aktive Projekte) als kompakte Karte mit Status-Dot.
|
||||||
|
Tap wechselt den Focus — Chat-Box filtert dann auf diesen Kontext. -->
|
||||||
|
<div id="chat-context-strip" style="display:flex;gap:6px;overflow-x:auto;padding:6px 4px;margin-bottom:6px;border-bottom:1px solid #1E1E2E;">
|
||||||
|
<!-- wird von renderContextStrip() befuellt -->
|
||||||
|
</div>
|
||||||
<div class="chat-box" id="chat-box"></div>
|
<div class="chat-box" id="chat-box"></div>
|
||||||
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
|
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
|
||||||
<span><span style="animation:pulse 1s infinite;">💭</span> <span id="thinking-text">ARIA denkt...</span></span>
|
<span><span style="animation:pulse 1s infinite;">💭</span> <span id="thinking-text">ARIA denkt...</span></span>
|
||||||
@@ -1317,6 +1323,94 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const chatBox = document.getElementById('chat-box');
|
const chatBox = document.getElementById('chat-box');
|
||||||
|
|
||||||
|
// ── Multi-Threading: Kontext-Focus fuer Diagnostic-Chat ─────
|
||||||
|
// focusedContextId: leerer String = Hauptchat, sonst project_id.
|
||||||
|
// Gefiltert werden Bubbles per data-project-id-Match (siehe addChat).
|
||||||
|
// Send-Input uebergibt die Focus-ID ans Brain (via bridge → /chat).
|
||||||
|
let focusedContextId = localStorage.getItem('diag_focused_context_id') || '';
|
||||||
|
let diagQueueStatus = {};
|
||||||
|
let diagProjectsCache = [];
|
||||||
|
|
||||||
|
function updateChatVisibilityByFocus() {
|
||||||
|
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||||
|
if (!box) continue;
|
||||||
|
for (const el of box.querySelectorAll('.chat-msg')) {
|
||||||
|
const pid = el.dataset.projectId || '';
|
||||||
|
el.style.display = (pid === focusedContextId) ? '' : 'none';
|
||||||
|
}
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchDiagFocus(id) {
|
||||||
|
focusedContextId = id || '';
|
||||||
|
localStorage.setItem('diag_focused_context_id', focusedContextId);
|
||||||
|
updateChatVisibilityByFocus();
|
||||||
|
renderContextStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContextStrip() {
|
||||||
|
const strip = document.getElementById('chat-context-strip');
|
||||||
|
if (!strip) return;
|
||||||
|
const chip = (id, name, isFocus, dotColor, subline) => {
|
||||||
|
const bg = isFocus ? 'rgba(52,199,89,0.15)' : '#1E1E2E';
|
||||||
|
const border = isFocus ? '#34C759' : '#2A2A3E';
|
||||||
|
return `<div onclick="switchDiagFocus('${id}')" style="cursor:pointer;flex:0 0 auto;padding:6px 10px;background:${bg};border:1px solid ${border};border-radius:6px;display:flex;align-items:center;gap:6px;min-width:120px;">
|
||||||
|
<div style="width:8px;height:8px;border-radius:4px;background:${dotColor};"></div>
|
||||||
|
<div style="display:flex;flex-direction:column;min-width:0;">
|
||||||
|
<div style="color:${isFocus?'#34C759':'#E0E0F0'};font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;">${escapeHtml(name)}</div>
|
||||||
|
<div style="color:#8888AA;font-size:10px;">${subline}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
const dotFor = (key) => {
|
||||||
|
const s = diagQueueStatus[key];
|
||||||
|
if (!s) return { color: '#555570', label: '' };
|
||||||
|
if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' };
|
||||||
|
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
|
||||||
|
return { color: '#34C759', label: 'idle' };
|
||||||
|
};
|
||||||
|
const cards = [];
|
||||||
|
// Hauptchat
|
||||||
|
const mainDot = dotFor('__main__');
|
||||||
|
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle'));
|
||||||
|
// Projekte — nur active/ended, sortiert nach letzter Aktivitaet
|
||||||
|
for (const p of diagProjectsCache) {
|
||||||
|
if (p.status === 'archived') continue;
|
||||||
|
const d = dotFor(p.id);
|
||||||
|
const sub = d.label || `${p.turn_count} Turns`;
|
||||||
|
cards.push(chip(p.id, `📁 ${p.name}`, focusedContextId === p.id, d.color, sub));
|
||||||
|
}
|
||||||
|
strip.innerHTML = cards.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDiagQueueStatus() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brain/projects/queue-status');
|
||||||
|
const d = await r.json();
|
||||||
|
diagQueueStatus = d?.contexts || {};
|
||||||
|
renderContextStrip();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDiagProjectsCache() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brain/projects/list?include_archived=false');
|
||||||
|
const d = await r.json();
|
||||||
|
diagProjectsCache = d?.projects || [];
|
||||||
|
renderContextStrip();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Load: Projekte laden + Polling starten
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshDiagProjectsCache();
|
||||||
|
refreshDiagQueueStatus();
|
||||||
|
setInterval(refreshDiagQueueStatus, 2000);
|
||||||
|
// Projekt-Liste alle 15s neu holen (neue Anlagen, umbenennen)
|
||||||
|
setInterval(refreshDiagProjectsCache, 15000);
|
||||||
|
}, 500);
|
||||||
const pauseHint = document.getElementById('pause-hint');
|
const pauseHint = document.getElementById('pause-hint');
|
||||||
const btnScroll = document.getElementById('btn-scroll');
|
const btnScroll = document.getElementById('btn-scroll');
|
||||||
let ws;
|
let ws;
|
||||||
@@ -1723,6 +1817,7 @@
|
|||||||
location: p.location,
|
location: p.location,
|
||||||
ttsText: p.ttsText,
|
ttsText: p.ttsText,
|
||||||
backupTs: p.backupTs,
|
backupTs: p.backupTs,
|
||||||
|
projectId: p.projectId || '',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1911,8 +2006,10 @@
|
|||||||
if (!text && diagPendingFiles.length === 0) return;
|
if (!text && diagPendingFiles.length === 0) return;
|
||||||
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
||||||
if (text) {
|
if (text) {
|
||||||
addChat('sent', text, 'via RVS');
|
// Multi-Threading: mit fokussierter Kontext-ID senden.
|
||||||
send({ action: 'test_rvs', text });
|
// Bridge routet an /chat body.project_id — Brain queued per Kontext.
|
||||||
|
addChat('sent', text, 'via RVS', { projectId: focusedContextId });
|
||||||
|
send({ action: 'test_rvs', text, projectId: focusedContextId });
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
@@ -2178,12 +2275,18 @@
|
|||||||
// Thinking-Indikator ausblenden bei neuer Nachricht
|
// Thinking-Indikator ausblenden bei neuer Nachricht
|
||||||
updateThinkingIndicator({ activity: 'idle' });
|
updateThinkingIndicator({ activity: 'idle' });
|
||||||
|
|
||||||
|
// Projekt-Tag fuer Focus-Filter (Multi-Threading, 06/2026)
|
||||||
|
const projectId = (options && options.projectId) || '';
|
||||||
|
const hiddenByFocus = (typeof focusedContextId === 'string' && projectId !== focusedContextId);
|
||||||
|
|
||||||
// In beide Chat-Boxen schreiben (normal + Vollbild)
|
// In beide Chat-Boxen schreiben (normal + Vollbild)
|
||||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||||
if (!box) continue;
|
if (!box) continue;
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `chat-msg ${type}`;
|
el.className = `chat-msg ${type}`;
|
||||||
if (backupTs) el.dataset.ts = String(backupTs);
|
if (backupTs) el.dataset.ts = String(backupTs);
|
||||||
|
el.dataset.projectId = projectId;
|
||||||
|
if (hiddenByFocus) el.style.display = 'none';
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
box.appendChild(el);
|
box.appendChild(el);
|
||||||
box.scrollTop = box.scrollHeight;
|
box.scrollTop = box.scrollHeight;
|
||||||
|
|||||||
+11
-3
@@ -1001,18 +1001,26 @@ function sendToRVS_raw(msgObj) {
|
|||||||
freshWs.on("error", () => {});
|
freshWs.on("error", () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToRVS(text, isTrace) {
|
function sendToRVS(text, isTrace, projectId) {
|
||||||
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
||||||
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
||||||
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
||||||
// Diagnostic die Bubble live spiegeln koennen.
|
// Diagnostic die Bubble live spiegeln koennen.
|
||||||
|
//
|
||||||
|
// projectId (Multi-Threading 06/2026): optional — leerer/undefined String
|
||||||
|
// = Hauptchat, sonst project_id. Bridge liest payload.projectId und routet
|
||||||
|
// an /chat body.project_id — Brain queued per Kontext.
|
||||||
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
||||||
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
sendToRVS_raw({
|
sendToRVS_raw({
|
||||||
type: "chat",
|
type: "chat",
|
||||||
payload: { text, sender: "diagnostic" },
|
payload: {
|
||||||
|
text,
|
||||||
|
sender: "diagnostic",
|
||||||
|
projectId: projectId || "",
|
||||||
|
},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -2285,7 +2293,7 @@ wss.on("connection", (ws) => {
|
|||||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||||
} else if (msg.action === "test_rvs") {
|
} else if (msg.action === "test_rvs") {
|
||||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
|
||||||
} else if (msg.action === "reconnect_gateway") {
|
} else if (msg.action === "reconnect_gateway") {
|
||||||
connectGateway();
|
connectGateway();
|
||||||
} else if (msg.action === "reconnect_rvs") {
|
} else if (msg.action === "reconnect_rvs") {
|
||||||
|
|||||||
Reference in New Issue
Block a user