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:
@@ -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"`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||

|

|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+31
-10
@@ -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);
|
||||||
|
if (_waterfallDirection === 'newest') {
|
||||||
container.prepend(pair);
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user