feat(chat): Gedanken-Stream (App + Diagnostic)
Persistentes chronologisches Log was ARIA intern macht — gefuettert aus agent_activity-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, neue Eintraege kommen unten dran, lange Pausen werden mit Trennlinie + Minuten-Hint sichtbar gemacht. App (ChatScreen.tsx): - 💭-Icon in der Statusleiste neben 🗂️ und 🔍, zeigt Eintrags-Anzahl - Bottom-Sheet (60% Hoehe) mit chronologischer Liste, Tap auf Hintergrund schliesst, 🗑-Confirm zum Leeren - Persistierung in AsyncStorage (aria_thought_stream, capped 500) - Dedup gegen direkt aufeinanderfolgende identische Events Diagnostic (index.html): - 💭 Gedanken-Button im Chat-Test-Header neben „Vollbild" - Zentrales Modal (720px x 70vh), Live-Update wenn neue Eintraege kommen (autoscroll ans Ende), 🗑 Leeren-Button mit Confirm - Persistierung in localStorage, gleiche cap/dedup-Logik wie App Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -126,11 +126,24 @@ interface ChatMessage {
|
||||
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 ---
|
||||
|
||||
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
||||
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
|
||||
const MAX_STORED_MESSAGES = 500;
|
||||
const MAX_MEMORY_MESSAGES = 500;
|
||||
const MAX_THOUGHTS = 500;
|
||||
|
||||
// Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM
|
||||
// im Gespraechsmodus bei sehr vielen Nachrichten.
|
||||
@@ -252,6 +265,14 @@ const ChatScreen: React.FC = () => {
|
||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||
// 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
|
||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
|
||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||
@@ -1002,6 +1023,16 @@ const ChatScreen: React.FC = () => {
|
||||
const activity = (message.payload.activity as string) || 'idle';
|
||||
const tool = (message.payload.tool as string) || '';
|
||||
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
|
||||
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
|
||||
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
|
||||
@@ -1266,6 +1297,36 @@ const ChatScreen: React.FC = () => {
|
||||
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
||||
}, [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]);
|
||||
@@ -1953,7 +2014,13 @@ const ChatScreen: React.FC = () => {
|
||||
{connectionState === 'connected' ? 'Verbunden' :
|
||||
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
||||
@@ -2228,6 +2295,102 @@ const ChatScreen: React.FC = () => {
|
||||
</ErrorBoundary>
|
||||
) : 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).
|
||||
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. */}
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
|
||||
GPS-Position einblenden
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,6 +343,22 @@
|
||||
</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
|
||||
komplett ueber den Gehirn-Tab gegen die Vector-DB im aria-brain. -->
|
||||
|
||||
@@ -2166,6 +2183,9 @@
|
||||
}
|
||||
|
||||
function updateThinkingIndicator(msg) {
|
||||
// Gedanken-Stream fuettern — JEDES Event (auch idle als ✓ fertig)
|
||||
pushThought(msg.activity || '', msg.tool || '');
|
||||
|
||||
const indicators = [
|
||||
document.getElementById('thinking-indicator'),
|
||||
document.getElementById('thinking-indicator-fs'),
|
||||
@@ -2202,6 +2222,112 @@
|
||||
}, 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 ─────────────────────────────
|
||||
function renderVoiceList(voices) {
|
||||
const box = document.getElementById('xtts-voice-list');
|
||||
@@ -4696,6 +4822,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
loadThoughtStream();
|
||||
connectWS();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user