Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca899aaf5 | |||
| 15facf48eb | |||
| 71fc90fcb8 | |||
| 856701fb6f | |||
| 6037b62612 | |||
| 8f88cb0030 | |||
| c224562423 | |||
| 5c07aef526 | |||
| d54d37061f |
@@ -362,7 +362,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
|
||||||
|
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
|
||||||
|
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
|
||||||
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
|
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
|
||||||
- **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr)
|
- **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr)
|
||||||
- **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event
|
- **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event
|
||||||
|
|||||||
@@ -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 10403
|
versionCode 10406
|
||||||
versionName "0.1.4.3"
|
versionName "0.1.4.6"
|
||||||
// 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.4.3",
|
"version": "0.1.4.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -126,11 +126,24 @@ interface ChatMessage {
|
|||||||
sendAttempts?: number;
|
sendAttempts?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Eintrag im Gedanken-Stream — chronologisches Log dessen was ARIA
|
||||||
|
* intern macht (Brain-`agent_activity`-Events). Bleibt zwischen Denk-
|
||||||
|
* Phasen stehen, wird in AsyncStorage persistiert. */
|
||||||
|
interface ThoughtEntry {
|
||||||
|
ts: number;
|
||||||
|
/** Roh-Activity vom Brain: thinking, tool, assistant, idle (= ✓ fertig). */
|
||||||
|
activity: string;
|
||||||
|
/** Bei activity='tool' der Tool-Name, sonst leer. */
|
||||||
|
tool?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Konstanten ---
|
// --- Konstanten ---
|
||||||
|
|
||||||
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
||||||
|
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
|
||||||
const MAX_STORED_MESSAGES = 500;
|
const MAX_STORED_MESSAGES = 500;
|
||||||
const MAX_MEMORY_MESSAGES = 500;
|
const MAX_MEMORY_MESSAGES = 500;
|
||||||
|
const MAX_THOUGHTS = 500;
|
||||||
|
|
||||||
// Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM
|
// Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM
|
||||||
// im Gespraechsmodus bei sehr vielen Nachrichten.
|
// im Gespraechsmodus bei sehr vielen Nachrichten.
|
||||||
@@ -252,6 +265,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
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: ''});
|
||||||
|
// Gedanken-Stream: chronologisches Log dessen was ARIA intern macht.
|
||||||
|
// Wird aus agent_activity-Events gefuettert und in AsyncStorage persistiert.
|
||||||
|
const [thoughts, setThoughts] = useState<ThoughtEntry[]>([]);
|
||||||
|
const [thoughtsVisible, setThoughtsVisible] = useState(false);
|
||||||
|
// Spiegel der letzten Activity in einer Ref — verhindert dass aufeinander-
|
||||||
|
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den
|
||||||
|
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
|
||||||
|
const lastThoughtKeyRef = useRef<string>('');
|
||||||
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
|
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
|
||||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||||
@@ -270,6 +291,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const messageIdCounter = useRef(0);
|
const messageIdCounter = useRef(0);
|
||||||
|
// Spiegel der messages-Liste in einer Ref — Closures (z.B. dispatchWithAck-
|
||||||
|
// Retry) brauchen Zugriff auf den aktuellen Status einer Bubble.
|
||||||
|
const messagesRef = useRef<ChatMessage[]>([]);
|
||||||
// Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
|
// Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
|
||||||
// nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
|
// nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
|
||||||
// vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
|
// vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
|
||||||
@@ -334,8 +358,19 @@ const ChatScreen: React.FC = () => {
|
|||||||
// - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
|
// - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
|
||||||
// - Wenn online → status='sending', Timer fuer ACK-Erwartung.
|
// - Wenn online → status='sending', Timer fuer ACK-Erwartung.
|
||||||
// - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
|
// - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
|
||||||
|
// - Wenn die Bubble inzwischen 'delivered' ist (z.B. ARIA hat geantwortet
|
||||||
|
// bevor das ACK durchkam) → komplett abbrechen, keinen Retry mehr.
|
||||||
const dispatchWithAck = useCallback(
|
const dispatchWithAck = useCallback(
|
||||||
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
|
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
|
||||||
|
// Schutz: wenn die Bubble inzwischen delivered ist, Retry-Loop stoppen
|
||||||
|
// (kann bei verspaeteten ACKs oder manuellem Retry passieren wenn ARIA
|
||||||
|
// schon laengst geantwortet hat).
|
||||||
|
const current = messagesRef.current.find(m => m.clientMsgId === cmid);
|
||||||
|
if (current?.deliveryStatus === 'delivered') {
|
||||||
|
clearAckTimer(cmid);
|
||||||
|
pendingPayloads.current.delete(cmid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
pendingPayloads.current.set(cmid, { type, payload });
|
pendingPayloads.current.set(cmid, { type, payload });
|
||||||
const online = connectionStateRef.current === 'connected';
|
const online = connectionStateRef.current === 'connected';
|
||||||
if (!online) {
|
if (!online) {
|
||||||
@@ -350,6 +385,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
cmid,
|
cmid,
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ackTimers.current.delete(cmid);
|
ackTimers.current.delete(cmid);
|
||||||
|
// Vor dem Retry erneut pruefen ob die Bubble nicht inzwischen
|
||||||
|
// delivered wurde — sonst spawnen wir endlose Retries.
|
||||||
|
const fresh = messagesRef.current.find(m => m.clientMsgId === cmid);
|
||||||
|
if (fresh?.deliveryStatus === 'delivered') {
|
||||||
|
pendingPayloads.current.delete(cmid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (attempt >= MAX_SEND_ATTEMPTS) {
|
if (attempt >= MAX_SEND_ATTEMPTS) {
|
||||||
updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
|
updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
|
||||||
console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
|
console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
|
||||||
@@ -615,6 +657,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
mimeType: f.mimeType || '',
|
mimeType: f.mimeType || '',
|
||||||
serverPath: f.serverPath || '',
|
serverPath: f.serverPath || '',
|
||||||
})) as Attachment[];
|
})) as Attachment[];
|
||||||
|
// clientMsgId weiterreichen — Bridge spiegelt sie im chat_backup,
|
||||||
|
// damit wir lokale Bubbles per ID dedupen koennen statt nur per
|
||||||
|
// Text/Timestamp-Heuristik.
|
||||||
|
const cmid = typeof m.clientMsgId === 'string' ? m.clientMsgId : undefined;
|
||||||
return {
|
return {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: role as 'user' | 'aria',
|
sender: role as 'user' | 'aria',
|
||||||
@@ -622,20 +668,45 @@ 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,
|
||||||
|
...(cmid && { clientMsgId: cmid }),
|
||||||
|
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
|
||||||
|
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
|
const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
|
// ClientMsgIds die der Server kennt — lokale Bubbles mit der
|
||||||
|
// gleichen ID werden durch die Server-Version ersetzt.
|
||||||
|
const serverCmids = new Set(
|
||||||
|
fromServer.map(s => s.clientMsgId).filter((x): x is string => !!x)
|
||||||
|
);
|
||||||
// Lokal-only Bubbles erkennen + behalten:
|
// Lokal-only Bubbles erkennen + behalten:
|
||||||
// - Skill-Created-Notifications (skillCreated gesetzt)
|
// - Skill-Created-Notifications (skillCreated gesetzt)
|
||||||
// - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
|
// - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
|
||||||
// gesetzt UND text leer/Placeholder)
|
// gesetzt UND text leer/Placeholder)
|
||||||
const localOnly = prev.filter(m =>
|
// - User-Bubbles deren clientMsgId der Server noch nicht kennt:
|
||||||
m.skillCreated ||
|
// z.B. waehrend Reconnect-Race oder solange flushQueuedMessages
|
||||||
m.triggerCreated ||
|
// noch laeuft. ABER: wenn der Server eine textgleiche Bubble
|
||||||
m.memorySaved ||
|
// im gleichen 5-Min-Fenster hat (Alter Backup-Eintrag ohne
|
||||||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
|
// clientMsgId, vor dem Bridge-Patch geschrieben), werten wir
|
||||||
);
|
// das als Treffer und verwerfen die lokale Kopie — sonst
|
||||||
|
// Doppelpost: einmal als Server-Bubble (delivered) und einmal
|
||||||
|
// als lokale failed/queued mit Retry-Knopf.
|
||||||
|
const FIVE_MIN = 5 * 60 * 1000;
|
||||||
|
const localOnly = prev.filter(m => {
|
||||||
|
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
|
||||||
|
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
|
||||||
|
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
|
||||||
|
const serverHasIt = fromServer.some(s =>
|
||||||
|
s.sender === 'user' &&
|
||||||
|
s.text === m.text &&
|
||||||
|
Math.abs((s.timestamp || 0) - (m.timestamp || 0)) < FIVE_MIN,
|
||||||
|
);
|
||||||
|
if (serverHasIt) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
// Server-Stand + lokal-only (chronologisch sortiert)
|
// Server-Stand + lokal-only (chronologisch sortiert)
|
||||||
const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
|
const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
return capMessages(merged);
|
return capMessages(merged);
|
||||||
@@ -902,6 +973,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
|
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
|
||||||
clearStuckWatchdog();
|
clearStuckWatchdog();
|
||||||
|
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
|
||||||
|
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
|
||||||
|
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
|
||||||
|
// nachtraeglich loslaufen und die Bubble auf 'failed' setzen.
|
||||||
|
for (const cmid of Array.from(ackTimers.current.keys())) {
|
||||||
|
clearAckTimer(cmid);
|
||||||
|
pendingPayloads.current.delete(cmid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
|
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
|
||||||
@@ -944,10 +1023,23 @@ const ChatScreen: React.FC = () => {
|
|||||||
const activity = (message.payload.activity as string) || 'idle';
|
const activity = (message.payload.activity as string) || 'idle';
|
||||||
const tool = (message.payload.tool as string) || '';
|
const tool = (message.payload.tool as string) || '';
|
||||||
setAgentActivity({ activity, tool });
|
setAgentActivity({ activity, tool });
|
||||||
|
// In den Gedanken-Stream einfuegen. Dedup gegen identische Folge-
|
||||||
|
// Events (z.B. zwei mal 'thinking' direkt hintereinander).
|
||||||
|
const key = `${activity}|${tool}`;
|
||||||
|
if (key !== lastThoughtKeyRef.current) {
|
||||||
|
lastThoughtKeyRef.current = key;
|
||||||
|
setThoughts(prev => {
|
||||||
|
const next = [...prev, { ts: Date.now(), activity, tool }];
|
||||||
|
return next.length > MAX_THOUGHTS ? next.slice(-MAX_THOUGHTS) : next;
|
||||||
|
});
|
||||||
|
}
|
||||||
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
|
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
|
||||||
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
|
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
|
||||||
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
|
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
|
||||||
// activity-Event), Timer neu starten. 180s ohne Update → Hang.
|
// activity-Event), Timer neu starten. 21 Min ohne Update → Hang.
|
||||||
|
// Knapp ueber Brain-Timeout (20 Min) damit nur bei echten
|
||||||
|
// Verbindungsabbruechen / Brain-Crashes gefeuert wird, nicht waehrend
|
||||||
|
// legitimer langer Multi-Tool-Sessions die das Brain selbst kappt.
|
||||||
clearStuckWatchdog();
|
clearStuckWatchdog();
|
||||||
if (activity !== 'idle') {
|
if (activity !== 'idle') {
|
||||||
stuckWatchdog.current = setTimeout(() => {
|
stuckWatchdog.current = setTimeout(() => {
|
||||||
@@ -956,10 +1048,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
setMessages(prev => capMessages([...prev, {
|
setMessages(prev => capMessages([...prev, {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'aria',
|
sender: 'aria',
|
||||||
text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 3 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
|
text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 21 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}]));
|
}]));
|
||||||
}, 180_000);
|
}, 1_260_000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,6 +1300,40 @@ const ChatScreen: React.FC = () => {
|
|||||||
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// Gedanken-Stream beim Mount aus AsyncStorage laden
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.getItem(THOUGHT_STORAGE_KEY)
|
||||||
|
.then(raw => {
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) setThoughts(parsed.slice(-MAX_THOUGHTS));
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Gedanken-Stream persistieren (debounced)
|
||||||
|
const thoughtSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (thoughts.length === 0) {
|
||||||
|
AsyncStorage.removeItem(THOUGHT_STORAGE_KEY).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current);
|
||||||
|
thoughtSaveTimer.current = setTimeout(() => {
|
||||||
|
AsyncStorage.setItem(
|
||||||
|
THOUGHT_STORAGE_KEY,
|
||||||
|
JSON.stringify(thoughts.slice(-MAX_THOUGHTS)),
|
||||||
|
).catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
return () => { if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current); };
|
||||||
|
}, [thoughts]);
|
||||||
|
|
||||||
|
// messagesRef immer aktuell halten — wird von dispatchWithAck/Retry gelesen
|
||||||
|
// damit Retries auf den aktuellen deliveryStatus reagieren koennen.
|
||||||
|
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
||||||
|
|
||||||
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
||||||
// Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat
|
// Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat
|
||||||
// NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt.
|
// NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt.
|
||||||
@@ -1891,7 +2017,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
{connectionState === 'connected' ? 'Verbunden' :
|
{connectionState === 'connected' ? 'Verbunden' :
|
||||||
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={() => setInboxVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
<TouchableOpacity onPress={() => setThoughtsVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6, flexDirection: 'row', alignItems: 'center'}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
||||||
|
<Text style={{fontSize: 16}}>{'\uD83D\uDCAD'}</Text>
|
||||||
|
{thoughts.length > 0 ? (
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 11, marginLeft: 3}}>{thoughts.length}</Text>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setInboxVisible(true)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
||||||
<Text style={{fontSize: 18}}>{'\uD83D\uDDC2\uFE0F'}</Text>
|
<Text style={{fontSize: 18}}>{'\uD83D\uDDC2\uFE0F'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
||||||
@@ -2166,6 +2298,102 @@ const ChatScreen: React.FC = () => {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Gedanken-Stream — chronologisches Log von ARIAs interner Aktivitaet.
|
||||||
|
Bottom-Sheet (slide-up), 60% Bildschirmhoehe. Mülltonne zum Leeren. */}
|
||||||
|
<Modal
|
||||||
|
visible={thoughtsVisible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent
|
||||||
|
onRequestClose={() => setThoughtsVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setThoughtsVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}>
|
||||||
|
{/* Drag-Indicator */}
|
||||||
|
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
|
||||||
|
<View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
|
||||||
|
</View>
|
||||||
|
<View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
|
||||||
|
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>
|
||||||
|
{'💭'} Gedanken-Stream {thoughts.length > 0 ? `(${thoughts.length})` : ''}
|
||||||
|
</Text>
|
||||||
|
{thoughts.length > 0 ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert('Gedanken-Stream leeren?', `Alle ${thoughts.length} Eintraege werden geloescht.`, [
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{ text: 'Leeren', style: 'destructive', onPress: () => {
|
||||||
|
setThoughts([]);
|
||||||
|
lastThoughtKeyRef.current = '';
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
hitSlop={{top:8,bottom:8,left:8,right:8}}
|
||||||
|
style={{paddingHorizontal:8}}
|
||||||
|
>
|
||||||
|
<Text style={{fontSize:18}}>{'🗑'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
<TouchableOpacity onPress={() => setThoughtsVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||||
|
<Text style={{color:'#8888AA', fontSize:24}}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{thoughts.length === 0 ? (
|
||||||
|
<View style={{flex:1, alignItems:'center', justifyContent:'center', padding:24}}>
|
||||||
|
<Text style={{color:'#555570', fontSize:13, fontStyle:'italic', textAlign:'center'}}>
|
||||||
|
Noch keine Gedanken aufgezeichnet.{'\n'}Sobald ARIA was tut, taucht's hier auf.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={thoughts}
|
||||||
|
keyExtractor={(_, i) => `t_${i}`}
|
||||||
|
contentContainerStyle={{paddingVertical:8}}
|
||||||
|
renderItem={({ item, index }) => {
|
||||||
|
const prev = index > 0 ? thoughts[index - 1] : null;
|
||||||
|
// Lange Pause? → Trenn-Linie mit Minuten-Hint
|
||||||
|
const gapMin = prev ? Math.floor((item.ts - prev.ts) / 60000) : 0;
|
||||||
|
const showGap = gapMin >= 1;
|
||||||
|
const time = new Date(item.ts).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
|
||||||
|
const icon =
|
||||||
|
item.activity === 'idle' ? '✓' :
|
||||||
|
item.activity === 'tool' ? '🔧' :
|
||||||
|
item.activity === 'assistant' ? '✍️' :
|
||||||
|
item.activity === 'thinking' ? '💭' : '•';
|
||||||
|
const label =
|
||||||
|
item.activity === 'idle' ? 'fertig' :
|
||||||
|
item.activity === 'tool' ? (item.tool || 'tool') :
|
||||||
|
item.activity === 'assistant' ? 'schreibt' :
|
||||||
|
item.activity === 'thinking' ? 'denkt' : item.activity;
|
||||||
|
const isIdle = item.activity === 'idle';
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{showGap ? (
|
||||||
|
<View style={{flexDirection:'row', alignItems:'center', paddingHorizontal:16, paddingVertical:6}}>
|
||||||
|
<View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
|
||||||
|
<Text style={{color:'#555570', fontSize:10, paddingHorizontal:8}}>
|
||||||
|
{gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`}
|
||||||
|
</Text>
|
||||||
|
<View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={{flexDirection:'row', paddingHorizontal:16, paddingVertical:5}}>
|
||||||
|
<Text style={{color:'#555570', fontSize:11, width:78}}>{time}</Text>
|
||||||
|
<Text style={{fontSize:13, width:24}}>{icon}</Text>
|
||||||
|
<Text style={{color: isIdle ? '#34C759' : '#E0E0F0', fontSize:13, flex:1}}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
||||||
Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
|
Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
|
||||||
des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
|
des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
|
||||||
|
|||||||
+11
-10
@@ -164,15 +164,17 @@ def build_skills_section(skills: List[dict]) -> str:
|
|||||||
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
|
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
|
||||||
"Stefan fragen ob es ins Brain-Dockerfile soll.")
|
"Stefan fragen ob es ins Brain-Dockerfile soll.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("**Harte Regel — IMMER Skill anlegen wenn:** die Loesung erfordert eine "
|
lines.append("**Goldene Regel: NIE ungefragt Skills anlegen.** Selbst wenn die Aufgabe "
|
||||||
"pip-Library. Begruendung: Brain-Container hat keinen persistenten State "
|
"eine pip-Library braucht — erst die Aufgabe loesen (mit Bash, `pip install` "
|
||||||
"ausser /data/skills/. Ohne Skill wuerde der Install bei jedem "
|
"im Brain ist ok, oder Workaround), und nur wenn Stefan EXPLIZIT sagt "
|
||||||
"Container-Restart wiederholt.")
|
"'mach daraus einen Skill' / 'leg den als Skill an' / 'dafuer einen Skill' "
|
||||||
|
"rufst du `skill_create` auf. Begruendung: Skill-Setup (venv + pip install) "
|
||||||
|
"blockt das Brain bis zu 12 Minuten. Ein unaufgefordert angelegter Skill "
|
||||||
|
"macht ARIA stumm und nervt Stefan jedes Mal.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("**Sonst — Skill nur wenn alle vier zutreffen:**")
|
lines.append("**Wenn Stefan einen Skill explizit moechte, pruef:**")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt. "
|
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt.")
|
||||||
"Einmal-Faelle (\"wie spaet ist es jetzt\") kein Skill.")
|
|
||||||
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
|
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
|
||||||
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
|
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
|
||||||
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
|
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
|
||||||
@@ -180,9 +182,8 @@ def build_skills_section(skills: List[dict]) -> str:
|
|||||||
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
|
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
|
||||||
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
|
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Wenn nichts installiert werden muss UND nicht alle vier zutreffen: einfach "
|
lines.append("Wenn auch nur EINE der vier nicht zutrifft: hoeflich nachfragen ob er "
|
||||||
"die Aufgabe loesen ohne Skill anzulegen. Stefan kann jederzeit sagen "
|
"wirklich einen permanenten Skill will oder die Aufgabe einmalig reicht.")
|
||||||
"'bau daraus einen Skill'.")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
||||||
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
||||||
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
||||||
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "300"))
|
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200"))
|
||||||
|
|
||||||
|
|
||||||
def _read_model_from_runtime() -> str:
|
def _read_model_from_runtime() -> str:
|
||||||
|
|||||||
+34
-11
@@ -1316,10 +1316,12 @@ class ARIABridge:
|
|||||||
self._pending_files_flush_task = None
|
self._pending_files_flush_task = None
|
||||||
text = self._build_pending_files_message(user_text)
|
text = self._build_pending_files_message(user_text)
|
||||||
self._pending_files = []
|
self._pending_files = []
|
||||||
await self.send_to_core(text, source="app-file+chat")
|
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||||
|
# fertig ist (siehe chat-handler oben).
|
||||||
|
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") -> None:
|
async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> 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
|
||||||
@@ -1333,8 +1335,13 @@ class ARIABridge:
|
|||||||
logger.info("[brain] chat ← %s '%s'", source, text[:80])
|
logger.info("[brain] chat ← %s '%s'", source, text[:80])
|
||||||
|
|
||||||
# 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.
|
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
|
||||||
self._append_chat_backup({"role": "user", "text": text, "source": source})
|
# damit die App beim chat_history_response ihre lokale Bubble
|
||||||
|
# dedupen kann (sonst verschwindet sie nach Offline→Online-Race).
|
||||||
|
entry: dict = {"role": "user", "text": text, "source": source}
|
||||||
|
if client_msg_id:
|
||||||
|
entry["clientMsgId"] = client_msg_id
|
||||||
|
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
|
||||||
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
|
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
|
||||||
@@ -1346,8 +1353,10 @@ class ARIABridge:
|
|||||||
url, data=payload, method="POST",
|
url, data=payload, method="POST",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
# Cold-Start kann lange dauern, 5min Timeout
|
# 20 Min Timeout — lange Multi-Tool-Workflows (Karten,
|
||||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
# PDFs, viele curl-Calls) brauchen das. 5 Min waren chronisch
|
||||||
|
# zu knapp und haben ARIA mitten in der Arbeit gekappt.
|
||||||
|
with urllib.request.urlopen(req, timeout=1200) as resp:
|
||||||
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return None, str(exc)
|
return None, str(exc)
|
||||||
@@ -1634,7 +1643,16 @@ class ARIABridge:
|
|||||||
" [BARGE-IN]" if interrupted else "",
|
" [BARGE-IN]" if interrupted else "",
|
||||||
" [GPS]" if location else "",
|
" [GPS]" if location else "",
|
||||||
text[:80])
|
text[:80])
|
||||||
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
|
# KEIN await: send_to_core kann 20 Min dauern. Wenn wir
|
||||||
|
# hier awaiten, blockt der `async for raw_message in ws`-
|
||||||
|
# Loop solange → RVS-Server droppt uns nach ~4 Min idle.
|
||||||
|
# Als Task: Brain laeuft im Hintergrund, RVS-recv bleibt
|
||||||
|
# bedienbar, Pings werden beantwortet, Verbindung lebt.
|
||||||
|
asyncio.create_task(self.send_to_core(
|
||||||
|
core_text,
|
||||||
|
source="app" + (" [barge-in]" if interrupted else ""),
|
||||||
|
client_msg_id=client_msg_id,
|
||||||
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
if msg_type == "cancel_request":
|
if msg_type == "cancel_request":
|
||||||
@@ -1810,7 +1828,8 @@ class ARIABridge:
|
|||||||
|
|
||||||
if not file_b64:
|
if not file_b64:
|
||||||
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
||||||
await self.send_to_core(text, source="app-file")
|
# create_task statt await — RVS-recv darf nicht blocken
|
||||||
|
asyncio.create_task(self.send_to_core(text, source="app-file"))
|
||||||
return
|
return
|
||||||
|
|
||||||
if file_type.startswith("image/"):
|
if file_type.startswith("image/"):
|
||||||
@@ -2234,7 +2253,8 @@ class ARIABridge:
|
|||||||
" [GPS]" if location else "",
|
" [GPS]" if location else "",
|
||||||
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
|
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
|
||||||
asyncio.create_task(self._process_app_audio(
|
asyncio.create_task(self._process_app_audio(
|
||||||
audio_b64, mime_type, interrupted, audio_request_id, location))
|
audio_b64, mime_type, interrupted, audio_request_id, location,
|
||||||
|
client_msg_id=client_msg_id))
|
||||||
|
|
||||||
elif msg_type == "stt_response":
|
elif msg_type == "stt_response":
|
||||||
# Antwort der whisper-bridge auf unseren stt_request
|
# Antwort der whisper-bridge auf unseren stt_request
|
||||||
@@ -2293,7 +2313,8 @@ class ARIABridge:
|
|||||||
async def _process_app_audio(self, audio_b64: str, mime_type: str,
|
async def _process_app_audio(self, audio_b64: str, mime_type: str,
|
||||||
interrupted: bool = False,
|
interrupted: bool = False,
|
||||||
audio_request_id: str = "",
|
audio_request_id: str = "",
|
||||||
location: Optional[dict] = None) -> None:
|
location: Optional[dict] = None,
|
||||||
|
client_msg_id: Optional[str] = None) -> None:
|
||||||
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
|
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
|
||||||
|
|
||||||
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
|
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
|
||||||
@@ -2349,7 +2370,9 @@ class ARIABridge:
|
|||||||
|
|
||||||
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
||||||
core_text = self._build_core_text(text, interrupted, location)
|
core_text = self._build_core_text(text, interrupted, location)
|
||||||
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
|
await self.send_to_core(core_text,
|
||||||
|
source="app-voice" + (" [barge-in]" if interrupted else ""),
|
||||||
|
client_msg_id=client_msg_id)
|
||||||
else:
|
else:
|
||||||
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
||||||
|
|
||||||
|
|||||||
@@ -301,6 +301,7 @@
|
|||||||
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
|
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
|
||||||
GPS-Position einblenden
|
GPS-Position einblenden
|
||||||
</label>
|
</label>
|
||||||
|
<button class="btn secondary" onclick="openThoughtStream()" id="btn-thoughts" title="Gedanken-Stream — was ARIA intern tut" style="padding:4px 10px;font-size:11px;">💭 Gedanken <span id="thoughts-count" style="color:#8888AA;"></span></button>
|
||||||
<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>
|
||||||
@@ -342,6 +343,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gedanken-Stream Modal — chronologisches Log was ARIA intern tut.
|
||||||
|
Zentrales Modal (max 720px breit), Liste mit Auto-Scroll ans Ende
|
||||||
|
wenn neue Eintraege reinkommen. -->
|
||||||
|
<div id="thought-stream-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.7);z-index:1100;align-items:center;justify-content:center;padding:24px;" onclick="if(event.target===this) closeThoughtStream();">
|
||||||
|
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:12px;width:100%;max-width:720px;height:70vh;display:flex;flex-direction:column;">
|
||||||
|
<div style="display:flex;align-items:center;padding:14px;border-bottom:1px solid #1E1E2E;">
|
||||||
|
<h2 style="margin:0;color:#FFD60A;flex:1;font-size:16px;">💭 Gedanken-Stream <span id="thoughts-count-modal" style="color:#8888AA;font-weight:normal;"></span></h2>
|
||||||
|
<button class="btn secondary" onclick="clearThoughtStream()" id="btn-clear-thoughts" title="Stream leeren" style="padding:4px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;margin-right:6px;">🗑 Leeren</button>
|
||||||
|
<button class="btn secondary" onclick="closeThoughtStream()" style="padding:4px 12px;">Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div id="thought-stream-list" style="flex:1;overflow-y:auto;padding:8px 0;font-size:13px;font-family:monospace;">
|
||||||
|
<!-- gefuellt durch renderThoughtStream() -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sessions + alter Brain-Viewer entfernt — Memories laufen jetzt
|
<!-- Sessions + alter Brain-Viewer entfernt — Memories laufen jetzt
|
||||||
komplett ueber den Gehirn-Tab gegen die Vector-DB im aria-brain. -->
|
komplett ueber den Gehirn-Tab gegen die Vector-DB im aria-brain. -->
|
||||||
|
|
||||||
@@ -2166,6 +2183,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateThinkingIndicator(msg) {
|
function updateThinkingIndicator(msg) {
|
||||||
|
// Gedanken-Stream fuettern — JEDES Event (auch idle als ✓ fertig)
|
||||||
|
pushThought(msg.activity || '', msg.tool || '');
|
||||||
|
|
||||||
const indicators = [
|
const indicators = [
|
||||||
document.getElementById('thinking-indicator'),
|
document.getElementById('thinking-indicator'),
|
||||||
document.getElementById('thinking-indicator-fs'),
|
document.getElementById('thinking-indicator-fs'),
|
||||||
@@ -2202,6 +2222,112 @@
|
|||||||
}, 120000);
|
}, 120000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gedanken-Stream ─────────────────────────────
|
||||||
|
// Chronologisches Log von agent_activity-Events. Wird in localStorage
|
||||||
|
// persistiert (ueberlebt Page-Reload), capped auf MAX_THOUGHTS.
|
||||||
|
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
|
||||||
|
const MAX_THOUGHTS = 500;
|
||||||
|
let thoughtStream = [];
|
||||||
|
let lastThoughtKey = '';
|
||||||
|
let _thoughtSaveTimer = null;
|
||||||
|
|
||||||
|
function loadThoughtStream() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(THOUGHT_STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) thoughtStream = parsed.slice(-MAX_THOUGHTS);
|
||||||
|
} catch {}
|
||||||
|
updateThoughtsBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistThoughtStream() {
|
||||||
|
if (_thoughtSaveTimer) clearTimeout(_thoughtSaveTimer);
|
||||||
|
_thoughtSaveTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (thoughtStream.length === 0) localStorage.removeItem(THOUGHT_STORAGE_KEY);
|
||||||
|
else localStorage.setItem(THOUGHT_STORAGE_KEY, JSON.stringify(thoughtStream.slice(-MAX_THOUGHTS)));
|
||||||
|
} catch {}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushThought(activity, tool) {
|
||||||
|
// Dedup gegen direkt aufeinanderfolgende identische Events
|
||||||
|
const key = `${activity}|${tool || ''}`;
|
||||||
|
if (key === lastThoughtKey) return;
|
||||||
|
lastThoughtKey = key;
|
||||||
|
thoughtStream.push({ ts: Date.now(), activity, tool: tool || '' });
|
||||||
|
if (thoughtStream.length > MAX_THOUGHTS) thoughtStream = thoughtStream.slice(-MAX_THOUGHTS);
|
||||||
|
updateThoughtsBadge();
|
||||||
|
// Wenn das Modal offen ist: live nachrendern + ans Ende scrollen
|
||||||
|
const modal = document.getElementById('thought-stream-modal');
|
||||||
|
if (modal && modal.style.display !== 'none') renderThoughtStream(true);
|
||||||
|
persistThoughtStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThoughtsBadge() {
|
||||||
|
const a = document.getElementById('thoughts-count');
|
||||||
|
if (a) a.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
|
||||||
|
const b = document.getElementById('thoughts-count-modal');
|
||||||
|
if (b) b.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openThoughtStream() {
|
||||||
|
const modal = document.getElementById('thought-stream-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
renderThoughtStream(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeThoughtStream() {
|
||||||
|
const modal = document.getElementById('thought-stream-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearThoughtStream() {
|
||||||
|
if (thoughtStream.length === 0) return;
|
||||||
|
if (!confirm(`Gedanken-Stream leeren? ${thoughtStream.length} Eintraege werden geloescht.`)) return;
|
||||||
|
thoughtStream = [];
|
||||||
|
lastThoughtKey = '';
|
||||||
|
updateThoughtsBadge();
|
||||||
|
renderThoughtStream(false);
|
||||||
|
persistThoughtStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThoughtStream(autoscroll) {
|
||||||
|
const list = document.getElementById('thought-stream-list');
|
||||||
|
if (!list) return;
|
||||||
|
if (thoughtStream.length === 0) {
|
||||||
|
list.innerHTML = '<div style="padding:24px;text-align:center;color:#555570;font-style:italic;">Noch keine Gedanken aufgezeichnet.<br>Sobald ARIA was tut, taucht\'s hier auf.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
let prevTs = 0;
|
||||||
|
for (const t of thoughtStream) {
|
||||||
|
const gapMin = prevTs ? Math.floor((t.ts - prevTs) / 60000) : 0;
|
||||||
|
if (gapMin >= 1) {
|
||||||
|
const label = gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`;
|
||||||
|
rows.push(`<div style="display:flex;align-items:center;padding:6px 16px;gap:8px;"><div style="flex:1;height:1px;background:#1E1E2E;"></div><span style="color:#555570;font-size:10px;">${label}</span><div style="flex:1;height:1px;background:#1E1E2E;"></div></div>`);
|
||||||
|
}
|
||||||
|
prevTs = t.ts;
|
||||||
|
const d = new Date(t.ts);
|
||||||
|
const time = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
|
||||||
|
let icon, label, color;
|
||||||
|
if (t.activity === 'idle') { icon = '✓'; label = 'fertig'; color = '#34C759'; }
|
||||||
|
else if (t.activity === 'tool') { icon = '🔧'; label = t.tool || 'tool'; color = '#E0E0F0'; }
|
||||||
|
else if (t.activity === 'assistant'){ icon = '✍️'; label = 'schreibt'; color = '#E0E0F0'; }
|
||||||
|
else if (t.activity === 'thinking'){ icon = '💭'; label = 'denkt'; color = '#E0E0F0'; }
|
||||||
|
else { icon = '•'; label = t.activity; color = '#E0E0F0'; }
|
||||||
|
rows.push(`<div style="display:flex;padding:4px 16px;align-items:baseline;"><span style="color:#555570;width:78px;font-size:11px;">${time}</span><span style="width:24px;">${icon}</span><span style="color:${color};flex:1;">${_escapeHtml(label)}</span></div>`);
|
||||||
|
}
|
||||||
|
list.innerHTML = rows.join('');
|
||||||
|
if (autoscroll) list.scrollTop = list.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
// ── XTTS Panel ─────────────────────────────
|
// ── XTTS Panel ─────────────────────────────
|
||||||
function renderVoiceList(voices) {
|
function renderVoiceList(voices) {
|
||||||
const box = document.getElementById('xtts-voice-list');
|
const box = document.getElementById('xtts-voice-list');
|
||||||
@@ -4696,6 +4822,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadThoughtStream();
|
||||||
connectWS();
|
connectWS();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
|
||||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
claude-max-api"
|
claude-max-api"
|
||||||
|
|||||||
@@ -341,10 +341,19 @@ Skills mit Tool-Use.
|
|||||||
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
|
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
|
||||||
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
|
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
|
||||||
|
|
||||||
|
### Chat-Stabilitaet: Such-Scroll, Stuck-Watchdog, Delivery-Handshake
|
||||||
|
|
||||||
|
- [x] **Such-Scroll springt nicht mehr permanent**: `onScrollToIndexFailed` hatte 3 cascading `setTimeout`s (120/320/600 ms) — jeder failed Retry triggerte den Handler wieder → 3, 9, 27 Scrolls in der Pipeline. Plus `invertedMessages` war in den useEffect-Deps: jede neue ARIA-Nachricht re-triggerte den Such-Scroll. Fix: nur EIN Retry nach 300 ms, in einer Ref-getrackten Timer-Variable; bei neuem Such-Hit wird der pending Retry gecancelt. `invertedMessages`-Snapshot via Ref statt Dep
|
||||||
|
- [x] **Jump-to-Bottom-Button** rechts unten in der Chat-Liste — taucht ab ~250 px Scroll-Weg auf, scrollt zur neuesten Nachricht (bei inverted FlatList `scrollToOffset(0)`)
|
||||||
|
- [x] **AsyncStorage-Init-Race**: zwischen Mount und „Verlauf aus AsyncStorage geladen" konnte eine User-Nachricht oder ein WS-Event ankommen — `setMessages(parsed)` ueberschrieb's mit dem alten Stand und die frische Nachricht war spurlos weg. Fix: Merge per `id` (frischere `prev`-Eintraege schlagen Gespeichertes), sortiert nach `timestamp`. `messageIdCounter` wird nur noch erhoeht, nie zurueckgesetzt
|
||||||
|
- [x] **Stuck-Thinking-Watchdog**: „ARIA denkt..." blieb gelegentlich kleben (Brain-Crash, WS-Disconnect ohne idle-Event, Cancel mit Race). Fix: jeder `agent_activity != idle` armiert einen 180s-Timer; ohne neues Lebenszeichen geht's auto-idle + Bubble „⚠ Habe gerade keine Verbindung zurueck bekommen". Watchdog wird beim ARIA-Reply, beim Cancel/Barge-In und beim Screen-Unmount gecleart
|
||||||
|
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
|
||||||
|
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
|
||||||
|
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
|
||||||
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
||||||
|
|
||||||
### Architektur
|
### Architektur
|
||||||
|
|||||||
Reference in New Issue
Block a user