fix: Chat-Chronologie + Auto-Scroll + KB-Hints — 8 Bugs behoben [appimage]
Some checks failed
Build AppImage / build (push) Has been cancelled

- Messages werden nach Timestamp sortiert + Race-Condition-sicherer Merge beim DB-Load
- ChatPanel.scrollToBottom() auf falschem Container entfernt, Scroll nur noch in MessageList
- userScrolledUp wird bei Session-Wechsel resettet (blockierte Auto-Scroll)
- Instant-Scroll Guard schneller freigegeben (1 statt 2 rAFs bei Streaming)
- KB-Hints: Session-Reset bei Chat-Wechsel, Relevance statt Priority im Ranking
- KB-Keywords nicht mehr akkumuliert (verhindert generische Dauertreffer)
- Cache-TTL 60s→15s, Hints alle 2 statt 3 Nachrichten

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-05-02 20:46:42 +02:00
parent 8d52505d14
commit 09a9513983
4 changed files with 57 additions and 44 deletions

View file

@ -68,9 +68,9 @@ impl KbCache {
} }
} }
// Globaler Cache — 60 Sekunden TTL // Globaler Cache — 15 Sekunden TTL (kurz genug damit thematische Wechsel ankommen)
static KB_CACHE: std::sync::LazyLock<Mutex<KbCache>> = static KB_CACHE: std::sync::LazyLock<Mutex<KbCache>> =
std::sync::LazyLock::new(|| Mutex::new(KbCache::new(60))); std::sync::LazyLock::new(|| Mutex::new(KbCache::new(15)));
// ============ Smart Hints v2: Session Topic Tracking ============ // ============ Smart Hints v2: Session Topic Tracking ============
// Akkumuliert Keywords über die Session hinweg, verhindert Wiederholungen, // Akkumuliert Keywords über die Session hinweg, verhindert Wiederholungen,
@ -314,29 +314,19 @@ pub async fn search_knowledge_internal(query: &str, limit: i32) -> Result<String
} }
} }
// Keywords zur Session hinzufügen (dedupliziert) // Keywords der aktuellen Nachricht merken (fuer shown_ids Tracking)
for kw in &new_keywords { // NICHT akkumulieren — sonst wird die Query generisch und liefert immer
if !topic.keywords.contains(kw) { // die gleichen "Favoriten"-Eintraege statt thematisch passende Hints.
topic.keywords.push(kw.clone()); topic.keywords = new_keywords.clone();
}
}
// Max 20 Keywords behalten (neueste bevorzugt)
if topic.keywords.len() > 20 {
let start = topic.keywords.len() - 20;
topic.keywords = topic.keywords[start..].to_vec();
}
topic.message_count += 1; topic.message_count += 1;
// Nur alle 3 Nachrichten frische Hints liefern (nicht jedes Mal!) // Alle 2 Nachrichten frische Hints liefern (statt alle 3)
// Erste Nachricht (count=1) bekommt immer Hints // Erste Nachricht (count=1) bekommt immer Hints
let skip = topic.message_count > 1 && topic.message_count % 3 != 1; let skip = topic.message_count > 1 && topic.message_count % 2 != 0;
// Session-Query bauen: letzte 6 Keywords für breiteren Kontext // Query aus aktuellen Keywords — direkt und thematisch
let query = if topic.keywords.len() > 3 { let query = if new_keywords.is_empty() {
let start = topic.keywords.len().saturating_sub(6);
topic.keywords[start..].join(" ")
} else if new_keywords.is_empty() {
safe_truncate(query, 100).to_string() safe_truncate(query, 100).to_string()
} else { } else {
new_keywords.join(" ") new_keywords.join(" ")
@ -399,12 +389,13 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O
WHERE status = 'active' WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY ORDER BY
CASE WHEN tags LIKE ? THEN 1 ELSE 0 END DESC, CASE WHEN tags LIKE ? THEN 10 ELSE 0 END +
priority DESC, (MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) * 100) +
usage_count DESC, (priority * 5) +
relevance DESC LEAST(usage_count, 50)
DESC
LIMIT ?"#, LIMIT ?"#,
(search_query, search_query, &proj_pattern, fetch_limit), (search_query, search_query, &proj_pattern, search_query, fetch_limit),
).await.map_err(|e| e.to_string())? ).await.map_err(|e| e.to_string())?
} else { } else {
conn.exec( conn.exec(
@ -416,9 +407,13 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O
FROM knowledge FROM knowledge
WHERE status = 'active' WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY priority DESC, usage_count DESC, relevance DESC ORDER BY
(MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) * 100) +
(priority * 5) +
LEAST(usage_count, 50)
DESC
LIMIT ?"#, LIMIT ?"#,
(search_query, search_query, fetch_limit), (search_query, search_query, search_query, fetch_limit),
).await.map_err(|e| e.to_string())? ).await.map_err(|e| e.to_string())?
}; };

View file

@ -388,22 +388,17 @@
let silenceStartTime: number | null = null; let silenceStartTime: number | null = null;
let vadEnabled = $state(true); // VAD ein/aus let vadEnabled = $state(true); // VAD ein/aus
async function scrollToBottom() { // Scroll wird komplett von MessageList verwaltet (hat den echten scroll-Container).
await tick(); // ChatPanel.scrollToBottom() war auf messagesContainer (Wrapper ohne overflow) und
if (messagesContainer) { // tat nichts — entfernt um Konflikte mit MessageList zu vermeiden.
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
$effect(() => { // Bei Session-Wechsel: Compacting-Flag + KB-Hints zurücksetzen
if ($messages.length) scrollToBottom();
});
// Bei Session-Wechsel: Compacting-Flag zurücksetzen
$effect(() => { $effect(() => {
if ($currentSessionId) { if ($currentSessionId) {
compactingWarningShown = false; compactingWarningShown = false;
showCompactingDialog = false; showCompactingDialog = false;
// KB-Hints Session-Topic zurücksetzen damit neue Hints kommen
invoke('reset_kb_session').catch(() => {});
} }
}); });
@ -1199,6 +1194,7 @@
<div class="chat-messages-wrap" bind:this={messagesContainer}> <div class="chat-messages-wrap" bind:this={messagesContainer}>
<MessageList <MessageList
sessionId={$currentSessionId}
{streamingMessageId} {streamingMessageId}
onEdit={handleEditById} onEdit={handleEditById}
onRegenerate={handleRegenerateById} onRegenerate={handleRegenerateById}

View file

@ -14,6 +14,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
interface Props { interface Props {
sessionId?: string | null;
streamingMessageId?: string | null; streamingMessageId?: string | null;
onEdit?: (id: string) => void; onEdit?: (id: string) => void;
onRegenerate?: (id: string) => void; onRegenerate?: (id: string) => void;
@ -21,6 +22,7 @@
onRewind?: (id: string) => void; onRewind?: (id: string) => void;
} }
let { let {
sessionId = null,
streamingMessageId = null, streamingMessageId = null,
onEdit, onEdit,
onRegenerate, onRegenerate,
@ -82,10 +84,10 @@
autoScrollTimer = setTimeout(releaseAutoScroll, 700); autoScrollTimer = setTimeout(releaseAutoScroll, 700);
} else { } else {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
// Instant-Scroll: ein rAF reicht damit das onscroll-Event durch ist // Instant-Scroll: Guard sofort nach einem rAF loesen.
requestAnimationFrame(() => { // Zwei rAFs waren zu langsam bei schnellem Streaming — der naechste
requestAnimationFrame(releaseAutoScroll); // Token kam bevor der Guard aufgehoben war.
}); requestAnimationFrame(releaseAutoScroll);
} }
} }
@ -130,6 +132,16 @@
} }
}); });
// Session-Wechsel: Scroll-State zuruecksetzen damit neue Session
// immer am Ende angezeigt wird (nicht vom alten userScrolledUp blockiert)
$effect(() => {
if (sessionId) {
userScrolledUp = false;
lastRole = null;
requestAnimationFrame(() => scrollToBottom(true));
}
});
onMount(() => { onMount(() => {
// ResizeObserver fuer den Container: feuert wenn sich die Hoehe // ResizeObserver fuer den Container: feuert wenn sich die Hoehe
// aendert (Tool-Card aufklappen, Diff-Erweitern, Code-Block rendern). // aendert (Tool-Card aufklappen, Diff-Erweitern, Code-Block rendern).

View file

@ -435,9 +435,19 @@ export function dbToMessage(db: DbMessage): Message {
}; };
} }
// Nachrichten aus DB in Store laden // Nachrichten aus DB in Store laden — mit Sortierung und Merge-Schutz
export function setMessagesFromDb(dbMessages: DbMessage[]) { export function setMessagesFromDb(dbMessages: DbMessage[]) {
messages.set(dbMessages.map(dbToMessage)); const mapped = dbMessages.map(dbToMessage);
mapped.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
messages.update(existing => {
// Wenn waehrend des Ladens schon neue Messages reinkamen (Streaming): mergen
const dbIds = new Set(mapped.map(m => m.id));
const newOnly = existing.filter(m => !dbIds.has(m.id));
if (newOnly.length === 0) return mapped;
return [...mapped, ...newOnly].sort((a, b) =>
a.timestamp.getTime() - b.timestamp.getTime()
);
});
} }
// ============ System-Monitor ============ // ============ System-Monitor ============