B6: waterfall direction toggle (v0.19.2)

NEW/OLD button toggles between newest-first and oldest-first ordering.
scrollToTop() replaced by scrollToLatest() which checks direction.
appendMessage() uses prepend/append based on direction.
Preference persisted in localStorage.
This commit is contained in:
gramps
2026-07-14 08:19:26 -07:00
parent b3746949a9
commit 4f16a9c078
4 changed files with 51 additions and 15 deletions
+2 -2
View File
@@ -138,6 +138,7 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
### Completed this session ### Completed this session
- **B7 (v0.19.0)** — Apple Silicon worker support. `gpu.py` now detects `sys.platform == "darwin"` and parses `system_profiler SPDisplaysDataType` for GPU model/VRAM instead of `rocm-smi`. `hardware.py` has darwin branch via `_get_vram_darwin()`. `node_agent/agent.py` reports VRAM on macOS via `system_profiler`. 5 new tests cover linux/darwin gpu paths, 3 new hardware tests cover darwin assessment + VRAM parsing. - **B7 (v0.19.0)** — Apple Silicon worker support. `gpu.py` now detects `sys.platform == "darwin"` and parses `system_profiler SPDisplaysDataType` for GPU model/VRAM instead of `rocm-smi`. `hardware.py` has darwin branch via `_get_vram_darwin()`. `node_agent/agent.py` reports VRAM on macOS via `system_profiler`. 5 new tests cover linux/darwin gpu paths, 3 new hardware tests cover darwin assessment + VRAM parsing.
- **B5 (v0.19.1)** — Default model auto-pull on first start. `model_pull.py` with `ensure_model()` checks llama-server availability, falls back to Ollama pull API. Integrated into `app.py` lifespan. 11 tests cover all paths. - **B5 (v0.19.1)** — Default model auto-pull on first start. `model_pull.py` with `ensure_model()` checks llama-server availability, falls back to Ollama pull API. Integrated into `app.py` lifespan. 11 tests cover all paths.
- **B6 (v0.19.2)** — Waterfall direction toggle. NEW/OLD topbar button toggles between newest-first and oldest-first ordering. `scrollToTop()` replaced by `scrollToLatest()` which checks direction. Preference persisted in localStorage.
- **Scroll-position fighting** — `scrollToTop()` now respects `_userScrolledAway` flag (100px threshold), skips auto-scroll when user is reading older content. `resetScrollLock()` called on new messages. - **Scroll-position fighting** — `scrollToTop()` now respects `_userScrolledAway` flag (100px threshold), skips auto-scroll when user is reading older content. `resetScrollLock()` called on new messages.
- **401 error cascade** — `SESSION_TIMEOUT_SECONDS` bumped 90→3600 (1 hour). All 10 unprotected `authFetch` calls wrapped in try/catch. - **401 error cascade** — `SESSION_TIMEOUT_SECONDS` bumped 90→3600 (1 hour). All 10 unprotected `authFetch` calls wrapped in try/catch.
- **Token counter** — removed localStorage persistence; resets to 0 on page refresh. - **Token counter** — removed localStorage persistence; resets to 0 on page refresh.
@@ -157,13 +158,12 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
### Upcoming (backlog) ### Upcoming (backlog)
- B4 — RAG Corpus Management UI - B4 — RAG Corpus Management UI
- B6 — waterfall direction toggle
- B8 — **Encryption & PHI readiness** — spec out encryption at rest (SQLCipher for caic.db, Qdrant payload encryption) and in-transit (TLS for inference, AMQP, RAG). Per-user auth, audit logging, log sanitizer, data lifecycle. Document the "personal LAN HIPAA gap." - B8 — **Encryption & PHI readiness** — spec out encryption at rest (SQLCipher for caic.db, Qdrant payload encryption) and in-transit (TLS for inference, AMQP, RAG). Per-user auth, audit logging, log sanitizer, data lifecycle. Document the "personal LAN HIPAA gap."
- **Preferred approach: Private Chat mode** — a toggle that skips DB persistence, memory/RAG injection, content logging, and **external SearXNG searching** entirely. Zero stored data, zero external queries = zero data to protect. Simpler, more robust, less code to audit than full encryption. Design this as the primary PHI path before reaching for crypto. - **Preferred approach: Private Chat mode** — a toggle that skips DB persistence, memory/RAG injection, content logging, and **external SearXNG searching** entirely. Zero stored data, zero external queries = zero data to protect. Simpler, more robust, less code to audit than full encryption. Design this as the primary PHI path before reaching for crypto.
- **In-transit still needs TLS** — Private Chat eliminates at-rest risk, but AMQP (RabbitMQ) and inference (llama-server) traffic between coordinator and workers is still plaintext on the wire. TLS termination on each node is the lightweight fix (self-signed CA, nginx sidecar or RabbitMQ TLS). - **In-transit still needs TLS** — Private Chat eliminates at-rest risk, but AMQP (RabbitMQ) and inference (llama-server) traffic between coordinator and workers is still plaintext on the wire. TLS termination on each node is the lightweight fix (self-signed CA, nginx sidecar or RabbitMQ TLS).
### Key config values (current) ### Key config values (current)
- `VERSION = "v0.19.1"` in `config.py` - `VERSION = "v0.19.2"` in `config.py`
- `SESSION_TIMEOUT_SECONDS = 3600` - `SESSION_TIMEOUT_SECONDS = 3600`
- `DEFAULT_MODEL = "qwen2.5-7b-instruct"` - `DEFAULT_MODEL = "qwen2.5-7b-instruct"`
- `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"` - `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"`
+16 -1
View File
@@ -1,6 +1,6 @@
![cAIc banner](static/readme-banner.png) ![cAIc banner](static/readme-banner.png)
# cAIc v0.19.1 # cAIc v0.19.2
Consumer AI hardware is a wasteland of incompatibility. NVIDIA speaks CUDA, AMD speaks ROCm. Your RTX 5070 Ti lives in one machine with 16 GB VRAM; your RX 6600 XT lives in another with 12 GB. Alone, neither can run a 14B model at usable speed. Together, they could — if the software stack didn't treat heterogeneous hardware as a bug instead of a feature. Consumer AI hardware is a wasteland of incompatibility. NVIDIA speaks CUDA, AMD speaks ROCm. Your RTX 5070 Ti lives in one machine with 16 GB VRAM; your RX 6600 XT lives in another with 12 GB. Alone, neither can run a 14B model at usable speed. Together, they could — if the software stack didn't treat heterogeneous hardware as a bug instead of a feature.
@@ -42,6 +42,13 @@ At v1.0, this ships with a Docker compose stack and setup wizard that detect CPU
Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) — includes [FAQ](https://llgit.llamachile.tube/gramps/cAIc/wiki/FAQ), [Installation Guide](https://llgit.llamachile.tube/gramps/cAIc/wiki/Installation), and [full architecture docs](https://llgit.llamachile.tube/gramps/cAIc/wiki/Developer-Architecture) Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) — includes [FAQ](https://llgit.llamachile.tube/gramps/cAIc/wiki/FAQ), [Installation Guide](https://llgit.llamachile.tube/gramps/cAIc/wiki/Installation), and [full architecture docs](https://llgit.llamachile.tube/gramps/cAIc/wiki/Developer-Architecture)
## What's New in v0.19.2
### Waterfall Direction Toggle (B6)
- **NEW/OLD toggle** — topbar button switches between newest-first (waterfall) and oldest-first (traditional chat)
- **Direction-aware scroll** — newest-first scrolls to top, oldest-first scrolls to bottom
- **localStorage persistence** — preference survives page reloads, default is newest-first (waterfall)
## What's New in v0.19.1 ## What's New in v0.19.1
### Default Model Auto-Pull on First Start (B5) ### Default Model Auto-Pull on First Start (B5)
@@ -49,6 +56,14 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
- **Startup integration** — `app.py` lifespan calls `ensure_model()` after `assess_hardware()`, pulling the missing model via Ollama's streaming pull API - **Startup integration** — `app.py` lifespan calls `ensure_model()` after `assess_hardware()`, pulling the missing model via Ollama's streaming pull API
- **Idempotent** — skips pull if model already available on llama-server or Ollama - **Idempotent** — skips pull if model already available on llama-server or Ollama
## What's New in v0.19.0
### Apple Silicon Worker Support (B7)
- **`gpu.py`** — now detects `sys.platform == "darwin"` and parses `system_profiler SPDisplaysDataType` for GPU model/VRAM instead of `rocm-smi`
- **`hardware.py`** — darwin branch via `_get_vram_darwin()` for VRAM assessment on macOS
- **`node_agent/agent.py`** — `get_load()` reports VRAM via `system_profiler SPDisplaysDataType` on macOS workers
- Hybrid detection — falls back to AMD rocm-smi on Linux, `available: False` if neither is detected
## What's New in v0.18.0 ## What's New in v0.18.0
### Wiki — Installation Guide, Screenshots Gallery, Full Documentation ### Wiki — Installation Guide, Screenshots Gallery, Full Documentation
+1 -1
View File
@@ -9,7 +9,7 @@ import logging
log = logging.getLogger("caic") log = logging.getLogger("caic")
VERSION = "v0.19.1" VERSION = "v0.19.2"
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434")
LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081")
SEARXNG_BASE = "http://localhost:8888" SEARXNG_BASE = "http://localhost:8888"
+32 -11
View File
@@ -401,6 +401,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
<button class="badge on" id="memoryBadge" onclick="toggleMemory()" title="Toggle memory injection">MEM</button> <button class="badge on" id="memoryBadge" onclick="toggleMemory()" title="Toggle memory injection">MEM</button>
<button class="badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">SEARCH</button> <button class="badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">SEARCH</button>
<button class="badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE</button> <button class="badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE</button>
<button class="badge on" id="directionBadge" onclick="toggleDirection()" title="Toggle message ordering">NEW</button>
<button class="badge admin" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button> <button class="badge admin" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
</div> </div>
</div> </div>
@@ -1101,7 +1102,7 @@ async function loadConversation(convId) {
container.innerHTML = ''; container.innerHTML = '';
conversationHistory = []; conversationHistory = [];
data.messages.forEach(msg => { appendMessage(msg.role, msg.content, false); conversationHistory.push({ role: msg.role, content: msg.content }); }); data.messages.forEach(msg => { appendMessage(msg.role, msg.content, false); conversationHistory.push({ role: msg.role, content: msg.content }); });
scrollToTop(); scrollToLatest();
await loadConversations(); await loadConversations();
} catch(e) {} } catch(e) {}
} }
@@ -1179,7 +1180,7 @@ async function sendSearch() {
if (data.error) { textEl.textContent = 'Error: ' + data.error; setStreamingState(false); return; } if (data.error) { textEl.textContent = 'Error: ' + data.error; setStreamingState(false); return; }
if (data.conversation_id && !currentConvId) { currentConvId = data.conversation_id; await loadConversations(); } if (data.conversation_id && !currentConvId) { currentConvId = data.conversation_id; await loadConversations(); }
if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results, summarizing...</div>'; } if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results, summarizing...</div>'; }
if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; tokenCount = 0; } fullText += data.token; tokenCount++; textEl.innerHTML = renderMarkdown(fullText); scrollToTop(); } if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; tokenCount = 0; } fullText += data.token; tokenCount++; textEl.innerHTML = renderMarkdown(fullText); scrollToLatest(); }
if (data.raw_results) { if (data.raw_results) {
let rawHtml = '<details class="raw-results"><summary>🔍 View raw search results (' + data.raw_results.length + ')</summary><ul>'; let rawHtml = '<details class="raw-results"><summary>🔍 View raw search results (' + data.raw_results.length + ')</summary><ul>';
data.raw_results.forEach(r => { data.raw_results.forEach(r => {
@@ -1386,7 +1387,7 @@ async function sendMessage() {
} }
if (data.searching) { textEl.innerHTML = fullText ? renderMarkdown(fullText) + '<div class="search-indicator"><div class="spinner"></div>Searching...</div>' : '<div class="search-indicator"><div class="spinner"></div>Searching...</div>'; searchTriggered = true; } if (data.searching) { textEl.innerHTML = fullText ? renderMarkdown(fullText) + '<div class="search-indicator"><div class="spinner"></div>Searching...</div>' : '<div class="search-indicator"><div class="spinner"></div>Searching...</div>'; searchTriggered = true; }
if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results...</div>'; fullText = ''; firstToken = true; } if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results...</div>'; fullText = ''; firstToken = true; }
if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; tokenCount = 0; } fullText += data.token; tokenCount++; textEl.innerHTML = renderMarkdown(fullText); scrollToTop(); } if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; tokenCount = 0; } fullText += data.token; tokenCount++; textEl.innerHTML = renderMarkdown(fullText); scrollToLatest(); }
if (data.done) { if (data.done) {
const roleLabel = assistantDiv.querySelector('.role-label'); const roleLabel = assistantDiv.querySelector('.role-label');
if (data.searched && roleLabel) roleLabel.innerHTML += '<span class="search-badge-inline">🔍 web</span>'; if (data.searched && roleLabel) roleLabel.innerHTML += '<span class="search-badge-inline">🔍 web</span>';
@@ -1460,19 +1461,27 @@ function appendMessage(role, content, animate, isSearch = false, afterEl = null)
const pair = document.createElement('div'); const pair = document.createElement('div');
pair.className = 'msg-pair' + (isSearch ? ' search-pair' : ''); pair.className = 'msg-pair' + (isSearch ? ' search-pair' : '');
pair.appendChild(div); pair.appendChild(div);
container.prepend(pair); if (_waterfallDirection === 'newest') {
container.prepend(pair);
} else {
container.appendChild(pair);
}
} else if (role === 'assistant') { } else if (role === 'assistant') {
const pair = afterEl ? afterEl.closest('.msg-pair') : container.querySelector('.msg-pair'); const pair = afterEl ? afterEl.closest('.msg-pair') : container.querySelector('.msg-pair');
if (pair) { if (pair) {
pair.appendChild(div); pair.appendChild(div);
} else { } else if (_waterfallDirection === 'newest') {
container.prepend(div); container.prepend(div);
} else {
container.appendChild(div);
} }
} else { } else if (_waterfallDirection === 'newest') {
container.prepend(div); container.prepend(div);
} else {
container.appendChild(div);
} }
if (content && role === 'assistant') { addCopyButtons(div); addMessageToolbar(div); } if (content && role === 'assistant') { addCopyButtons(div); addMessageToolbar(div); }
scrollToTop(); scrollToLatest();
return div; return div;
} }
@@ -1563,17 +1572,29 @@ function addMessageToolbar(msgDiv) {
} }
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
let _waterfallDirection = localStorage.getItem('waterfallDirection') || 'newest';
function toggleDirection() {
_waterfallDirection = _waterfallDirection === 'newest' ? 'oldest' : 'newest';
localStorage.setItem('waterfallDirection', _waterfallDirection);
const badge = document.getElementById('directionBadge');
if (badge) badge.textContent = _waterfallDirection === 'newest' ? 'NEW' : 'OLD';
}
let _userScrolledAway = false; let _userScrolledAway = false;
document.getElementById('chatContainer')?.addEventListener('scroll', function() { document.getElementById('chatContainer')?.addEventListener('scroll', function() {
_userScrolledAway = this.scrollTop > 100; _userScrolledAway = this.scrollTop > 100;
}, { passive: true }); }, { passive: true });
function scrollToBottom() { const c = document.getElementById('chatContainer'); c.scrollTop = c.scrollHeight; } function scrollToLatest() {
function scrollToTop() {
if (_userScrolledAway) return;
const c = document.getElementById('chatContainer'); const c = document.getElementById('chatContainer');
if (c) c.scrollTop = 0; if (!c) return;
if (_waterfallDirection === 'newest') {
if (_userScrolledAway) return;
c.scrollTop = 0;
} else {
c.scrollTop = c.scrollHeight;
}
} }
function resetScrollLock() { _userScrolledAway = false; } function resetScrollLock() { _userScrolledAway = false; }
function scrollToBottom() { scrollToLatest(); }
function execCopy(text) { function execCopy(text) {
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = text; ta.value = text;