perf(llm): route background tasks to dedicated model to preserve KV cache

Background tasks (title generation, tag suggestions, project summaries,
RSS classification) were using qwen3:8b and wiping its KV cache after
every response, preventing prefix cache hits on subsequent user messages.

Adds OLLAMA_BACKGROUND_MODEL (default: qwen2.5:0.5b) config var and
routes all background LLM calls to it, keeping qwen3:8b's KV cache
warm between user messages for consistent sub-second TTFT.

Also adds infinite scroll to KnowledgeView (replaces load-more button)
and bakes spaCy en_core_web_sm into the Docker image to eliminate the
pip install on every startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 01:33:54 -04:00
parent 888b736ecd
commit 750a91898a
9 changed files with 53 additions and 27 deletions
+41 -20
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { ref, computed, watch, onMounted, onUnmounted, nextTick, watchEffect } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPatch, transcribeAudio } from "@/api/client";
import { fmtCompact } from "@/utils/dateFormat";
@@ -320,17 +320,45 @@ function formatDate(iso: string): string {
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
// ─── Infinite scroll ──────────────────────────────────────────────────────────
const sentinelEl = ref<HTMLElement | null>(null);
let scrollObserver: IntersectionObserver | null = null;
function setupScrollObserver() {
if (scrollObserver) scrollObserver.disconnect();
if (!sentinelEl.value) return;
scrollObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading.value && items.value.length < total.value) {
page.value++;
fetchItems();
}
},
{ rootMargin: "200px" }
);
scrollObserver.observe(sentinelEl.value);
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(() => {
fetchItems(true);
fetchTags();
fetchTodayBar();
nextTick(() => chatInputEl.value?.focus());
nextTick(() => {
chatInputEl.value?.focus();
setupScrollObserver();
});
});
onUnmounted(() => {
if (searchDebounce) clearTimeout(searchDebounce);
scrollObserver?.disconnect();
});
watchEffect(() => {
if (sentinelEl.value) setupScrollObserver();
});
</script>
@@ -499,11 +527,9 @@ onUnmounted(() => {
</div>
</div>
<!-- Load more -->
<div v-if="items.length < total && !loading" class="load-more">
<button class="btn-load-more" @click="() => { page++; fetchItems() }">
Load more ({{ total - items.length }} remaining)
</button>
<!-- Infinite scroll sentinel -->
<div ref="sentinelEl" class="scroll-sentinel">
<span v-if="loading && items.length > 0" class="sentinel-loading">Loading</span>
</div>
</div>
@@ -975,23 +1001,18 @@ onUnmounted(() => {
}
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
/* ── Load more ───────────────────────────────────────────── */
.load-more {
padding: 16px 20px;
text-align: center;
/* ── Infinite scroll sentinel ────────────────────────────── */
.scroll-sentinel {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-load-more {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: transparent;
.sentinel-loading {
font-size: 0.8rem;
color: var(--color-muted);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s;
}
.btn-load-more:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
/* ── Graph panel ─────────────────────────────────────────── */
.graph-panel {