- {block.value.trimEnd()}
-
- ) : (
-
+ {value.trimEnd()}
+
+ ); - i++; - continue; + i++; continue; } - // Empty line β small gap if (line.trim() === "") { elements.push(); - i++; - continue; + i++; continue; } - // Regular paragraph line elements.push(
Memory
@@ -148,9 +328,49 @@ export function Memory() {
Available Models ({models.length})
diff --git a/interface/web/src/Playbook.jsx b/interface/web/src/Playbook.jsx
old mode 100644
new mode 100755
index 2e37aae..8706a07
--- a/interface/web/src/Playbook.jsx
+++ b/interface/web/src/Playbook.jsx
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { API_BASE } from "./config";
-export function Playbook({ status }) {
+export function Playbook() {
const [playbooks, setPlaybooks] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [form, setForm] = useState({ title: "", goal: "", instructions: "", tags: "" });
@@ -29,6 +29,7 @@ export function Playbook({ status }) {
}
}, []);
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount; setPlaybooks runs after await, not a synchronous cascading render
useEffect(() => { loadPlaybooks(); }, [loadPlaybooks]);
const safeString = (v) => (v == null ? "" : String(v));
@@ -184,7 +185,6 @@ export function Playbook({ status }) {
// Optimistic UI update β reorder within filtered set, keep unfiltered playbooks intact
setPlaybooks(prev => {
const filteredSet = new Set(currentOrder);
- const unfiltered = prev.filter(p => !filteredSet.has(String(p.id)));
const filteredMap = Object.fromEntries(prev.filter(p => filteredSet.has(String(p.id))).map(p => [String(p.id), p]));
const reorderedFiltered = newFilteredOrder.map(id => filteredMap[id]).filter(Boolean);
// Merge: place unfiltered items back in their original relative positions
diff --git a/interface/web/src/Settings.jsx b/interface/web/src/Settings.jsx
old mode 100644
new mode 100755
index 7016b55..fafebe7
--- a/interface/web/src/Settings.jsx
+++ b/interface/web/src/Settings.jsx
@@ -6,6 +6,10 @@ const DEFAULTS = {
temperature: 0.7,
system_prompt: "",
timeout: 120,
+ gpu_offload: -1, // -1 = Auto; 0β100 = percent of layers forced onto the GPU
+ memory_model: "mistral:latest", // curator model for memory extraction
+ memory_gpu_offload: 0, // curator offload; 0 = all CPU/RAM (safe on small GPUs)
+ memory_merge_threshold: 0.80, // 0 = off; else update near-duplicate facts in place
};
export function Settings() {
@@ -13,11 +17,25 @@ export function Settings() {
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
+ // Icon branding state
+ const [iconApps, setIconApps] = useState([]);
+ const [iconFilter, setIconFilter] = useState("");
+ const [iconFrac, setIconFrac] = useState(0.60);
+ const [iconRing, setIconRing] = useState(false);
+ const [selectedApps, setSelectedApps] = useState([]);
+ const [brandQueue, setBrandQueue] = useState([]);
+ const [isBranding, setIsBranding] = useState(false);
+
useEffect(() => {
fetch(`${API_BASE}/settings`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setForm(f => ({ ...f, ...d })); })
.catch(() => {});
+
+ fetch(`${API_BASE}/icons/apps`)
+ .then(r => r.ok ? r.json() : null)
+ .then(d => { if (d) setIconApps(d.apps || []); })
+ .catch(() => {});
}, []);
const update = (key, value) => setForm(f => ({ ...f, [key]: value }));
@@ -41,6 +59,73 @@ export function Settings() {
const reset = () => setForm(DEFAULTS);
+ // Icon branding logic
+ const toggleAppSelect = (app) => {
+ setSelectedApps(prev => {
+ const exists = prev.find(a => a.icon_name === app.icon_name);
+ if (exists) return prev.filter(a => a.icon_name !== app.icon_name);
+ return [...prev, app];
+ });
+ };
+
+ const removeFromQueue = (icon_name) => {
+ setSelectedApps(prev => prev.filter(a => a.icon_name !== icon_name));
+ };
+
+ const brandAll = async () => {
+ if (!selectedApps.length || isBranding) return;
+ setIsBranding(true);
+ const queue = selectedApps.map(a => ({ ...a, status: "pending" }));
+ setBrandQueue(queue);
+
+ for (let i = 0; i < queue.length; i++) {
+ const app = queue[i];
+ setBrandQueue(prev => prev.map((a, idx) => idx === i ? { ...a, status: "running" } : a));
+ try {
+ const r = await fetch(`${API_BASE}/icons/brand`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ src_path: app.icon_path,
+ output_name: app.icon_name,
+ frac: iconFrac,
+ nexus_ring: iconRing,
+ reload: false,
+ }),
+ });
+ if (!r.ok) throw new Error(await r.text());
+ setBrandQueue(prev => prev.map((a, idx) => idx === i ? { ...a, status: "done" } : a));
+ } catch (e) {
+ setBrandQueue(prev => prev.map((a, idx) => idx === i ? { ...a, status: "error", err: e.message } : a));
+ }
+ }
+
+ // Apply cache once after all are done
+ await fetch(`${API_BASE}/icons/apply`, { method: "POST" }).catch(() => {});
+
+ // Mark already_branded in the app list
+ setIconApps(prev => prev.map(a => {
+ if (selectedApps.find(s => s.icon_name === a.icon_name)) {
+ return { ...a, already_branded: true };
+ }
+ return a;
+ }));
+
+ setIsBranding(false);
+ };
+
+ const filteredApps = iconApps.filter(a =>
+ a.name.toLowerCase().includes(iconFilter.toLowerCase())
+ );
+
+ const statusIcon = (status) => {
+ if (status === "pending") return β;
+ if (status === "running") return β³;
+ if (status === "done") return β;
+ if (status === "error") return β;
+ return null;
+ };
+
const inputStyle = {
width: "100%",
padding: "0.6rem 0.75rem",
@@ -69,8 +154,10 @@ export function Settings() {
marginBottom: "1rem",
};
+ const displayQueue = isBranding ? brandQueue : selectedApps;
+
return (
-
+
Settings
{/* Generation */}
@@ -99,6 +186,140 @@ export function Settings() {
2.0 β creative
+
+
+
+ update("gpu_offload", parseInt(e.target.value))}
+ style={{ width: "100%", accentColor: "#b040c0", opacity: form.gpu_offload < 0 ? 0.4 : 1 }}
+ />
+
+ 0% β all CPU
+ 100% β all GPU
+
+
+
+ Lower values push more of the model onto the CPU β slower, but frees VRAM and reduces GPU load. Layer count is resolved per model.
+
+
+
Settings
{/* Generation */} @@ -99,6 +186,140 @@ export function Settings() { 2.0 β creative+ Lower values push more of the model onto the CPU β slower, but frees VRAM and reduces GPU load. Layer count is resolved per model. +
+Memory Curator
++ The model that reviews each exchange and saves new facts to memory, in the background after every reply. +
+ +
+ A dedicated model like mistral:latest extracts facts more reliably than a small chat model.
+
+ Keep at 0% (all CPU/RAM) on a small GPU so the curator doesn't evict the chat model β on a ~4 GB GPU that thrash makes extraction time out and silently save nothing. Raise it only if you have spare VRAM. +
++ When a newly extracted fact closely matches an existing one, update that entry instead of adding a duplicate. Uses embeddings. +
+ {form.memory_merge_threshold > 0 && ( + <> + + update("memory_merge_threshold", parseFloat(e.target.value))} + style={{ width: "100%", accentColor: "#b040c0" }} + /> +App Icon Branding
++ Select apps on the left to composite their icon onto the NexusOS tile. +
+ + {/* Controls row */} +