From d1fbe4ce43d4310bf277241500e2db5fd60d1c8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:20:31 +0000 Subject: [PATCH 1/3] Remove browser copies of conversation and feedback content PROJECT_SPEC section 8 states that the browser may persist only the allowlisted suggestion preferences and that prompt and response content is forbidden from persistent browser storage. The renderer still wrote the full conversation map to localStorage under dave_convos, wrote 200 characters of each rated response under dave_feedback, and silently restored the browser copy when the router was unreachable. That created a second, diverging conversation store beside dave_conversations.json. The renderer now treats the router as the only conversation source, leaves the list empty and logs the failure when the router cannot be reached, stops mirroring feedback content, and purges the two legacy keys at startup. The renderer contract test now asserts the absence of the browser mirror. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01656XvdqNAVojSPQBtLZAAt --- static/app.js | 33 +++++++++++------------------ tests/test_security_and_frontend.py | 10 +++++++++ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/static/app.js b/static/app.js index b079854..e9f9a9f 100644 --- a/static/app.js +++ b/static/app.js @@ -33,7 +33,10 @@ const state = { // --------------------------------------------- const ROUTER_BASE = (typeof window !== "undefined" && window.__API_BASE__) || (typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:8000"); -const LOCAL_STORAGE_KEY = "dave_convos"; +// Conversation and feedback content live only on the router. These legacy +// browser keys once mirrored them and are purged so no prompt or response +// text remains in persistent browser storage. +const LEGACY_CONTENT_STORAGE_KEYS = ["dave_convos", "dave_feedback"]; const LAST_SESSION_KEY = "dave_last_session"; const API_KEY_SESSION_KEY = "dave_api_key_session"; const THEME_STORAGE_KEY = "dave_theme"; @@ -190,9 +193,6 @@ function sendFeedback(score, content, modelId) { complexity: complexityScoreClient(content || "") }) }).catch(() => {}); - const fb = JSON.parse(localStorage.getItem("dave_feedback") || "[]"); - fb.push({ score, modelId: fbModel, content: content.slice(0, 200), ts: Date.now() }); - localStorage.setItem("dave_feedback", JSON.stringify(fb)); } catch (e) {} } @@ -931,15 +931,10 @@ async function createProjectFlow() { } } -function saveAllConversations() { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state.conversations)); -} - -function loadAllConversations() { - const saved = localStorage.getItem(LOCAL_STORAGE_KEY); - if (saved) { - state.conversations = JSON.parse(saved); - } +function purgeLegacyContentStorage() { + try { + LEGACY_CONTENT_STORAGE_KEYS.forEach((key) => localStorage.removeItem(key)); + } catch (e) {} } function toggleDictation() { @@ -1127,7 +1122,8 @@ async function downloadModelFromHF() { /** * Load all conversations from backend server. - * Replaces localStorage as source of truth when available. + * The router is the only source of truth; a failure leaves the list empty + * and visible as an error instead of restoring a browser copy. */ async function loadConversationsFromBackend() { try { @@ -1156,8 +1152,7 @@ async function loadConversationsFromBackend() { } catch (err) { console.error("Failed to load conversations from backend:", err); - console.warn("⚠️ Falling back to localStorage"); - loadAllConversations(); + state.conversations = {}; return false; } } @@ -1461,7 +1456,6 @@ async function deleteConversation(cid, element) { try { await deleteConversationFromBackend(cid); delete state.conversations[cid]; - saveAllConversations(); if (state.sessionId === cid) { const remaining = Object.keys(state.conversations); @@ -1492,7 +1486,6 @@ async function clearConversation(cid) { await clearConversationOnBackend(cid); const convo = state.conversations[cid]; convo.messages = []; - saveAllConversations(); renderMessages(); } catch (err) { console.error("Failed to clear conversation:", err); @@ -2672,9 +2665,6 @@ async function renameConversation(cid, element) { } else { console.log("ℹ️ New conversation, skipping backend sync (will sync on first message)"); } - - saveAllConversations(); - } catch (err) { console.error("❌ Error during finalize:", err); } finally { @@ -3967,6 +3957,7 @@ async function init() { restoredAnything = restoredAnything || preferredTemplate !== "general"; } + purgeLegacyContentStorage(); await loadConversationsFromBackend(); const lastSessionId = localStorage.getItem(LAST_SESSION_KEY); diff --git a/tests/test_security_and_frontend.py b/tests/test_security_and_frontend.py index 4341dd3..386db37 100644 --- a/tests/test_security_and_frontend.py +++ b/tests/test_security_and_frontend.py @@ -233,6 +233,16 @@ def test_displayed_prompt_contract_and_renderer_security(): assert "innerHTML" not in app_source assert "innerHTML" not in monitoring_source assert "localStorage.getItem(\"dave_api_key\")" not in app_source + monitoring_source + # The router is the only conversation and feedback store. The renderer must + # not mirror prompt or response content into persistent browser storage or + # restore a browser copy when the router is unreachable. + assert "saveAllConversations" not in app_source + assert "loadAllConversations" not in app_source + assert "localStorage.setItem(LOCAL_STORAGE_KEY" not in app_source + assert 'localStorage.setItem("dave_convos"' not in app_source + assert 'localStorage.setItem("dave_feedback"' not in app_source + assert 'LEGACY_CONTENT_STORAGE_KEYS = ["dave_convos", "dave_feedback"]' in app_source + assert "purgeLegacyContentStorage();" in app_source assert "DAVE_API_KEY" not in preload_source assert 'details.requestHeaders["X-API-Key"] = apiKey' in main_source assert main_source.index("await waitForBackend(child)") < main_source.index("createWindow();") From 9b6b543a103e098713c8bb36dafe5cd59e909502 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:21:17 +0000 Subject: [PATCH 2/3] Drop hardcoded local model paths from conversation templates The three conversation templates carried preferred_model values that were absolute llama.cpp GGUF paths on one personal desktop plus a relative ./models path. None of them can ever appear in an Ollama node inventory, and because the template value was consulted before the project's own setting, a project's preferred_model was never reported for a templated chat. Templates no longer carry a model preference and the from_template response reports only the project's preferred_model. The unused DEFAULT_MODEL_ID constant is removed with them. MODEL_CATALOG still holds the same legacy paths for the advisory routing and cost endpoints; that is reported separately because retiring or rewiring it is a product decision. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01656XvdqNAVojSPQBtLZAAt --- app.py | 13 ++++--------- tests/test_api_contracts.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 30b79fa..4250bac 100644 --- a/app.py +++ b/app.py @@ -101,11 +101,6 @@ ).expanduser() FFMPEG_BIN = os.getenv("FFMPEG_BIN") or shutil.which("ffmpeg") or "/opt/homebrew/bin/ffmpeg" -DEFAULT_MODEL_ID = ( - "/Users/daverobertson/Desktop/Dave-LLM/models/qwen-vl-7b/" - "Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf" -) - # Simple model catalog for routing decisions MODEL_CATALOG = { str(Path("/Users/daverobertson/Desktop/Dave-LLM/models/qwen-vl-7b/Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf").resolve()): { @@ -312,19 +307,16 @@ def record_error(event: str, detail: str): "title": "New Conversation", "system_prompt": SYSTEM_PROMPT, "session_override": "", - "preferred_model": DEFAULT_MODEL_ID, }, "code_review": { "title": "Code Review Session", "system_prompt": SYSTEM_PROMPT + "\n\nFocus on code quality, bugs, and optimization.", "session_override": "Focus on code quality, bugs, and optimization.", - "preferred_model": "./models/llama3.2-3b-instruct-q4_k_m.gguf", }, "brainstorm": { "title": "Brainstorm", "system_prompt": SYSTEM_PROMPT + "\n\nBe exploratory and propose multiple options.", "session_override": "Be exploratory and propose multiple options.", - "preferred_model": "/Users/daverobertson/Desktop/Dave-LLM/models/qwen-vl-7b/Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf", }, } @@ -3123,7 +3115,10 @@ def create_from_template(req: TemplateConversationRequest, user_id: str = Depend provisional_conversation, project_cfg, ) - preferred_model = template.get("preferred_model") or project_cfg.get("preferred_model") + # Templates carry no model preference. The only preferred model is the + # project's own setting, and the actual send still uses the visible + # inventory-backed selection. + preferred_model = project_cfg.get("preferred_model") cid = f"convo_{uuid.uuid4().hex}" CONVERSATIONS[cid] = { "title": template.get("title", DEFAULT_CONVO_TITLE), diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index 9a0fbd3..c8231e4 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -156,6 +156,22 @@ def test_template_body_and_authenticated_markdown_export(router_factory): assert response.json()["system_prompt"] == expected_prompt assert router.CONVERSATIONS[conversation_id]["system_prompt"] == expected_prompt assert router.CONVERSATIONS[conversation_id]["messages"] == [] + # Templates carry no source-controlled model path; a General chat has no + # preferred model and a project chat reports only the project's setting. + assert response.json()["preferred_model"] is None + assert all("preferred_model" not in template for template in router.TEMPLATES.values()) + project = client.post( + "/projects", + headers=AUTH, + json={"name": "Preferred", "preferred_model": MODEL_ID}, + ).json() + project_chat = client.post( + "/conversations/from_template", + headers=AUTH, + json={"template_name": "brainstorm", "project_id": project["project_id"]}, + ) + assert project_chat.status_code == 200 + assert project_chat.json()["preferred_model"] == MODEL_ID assert client.get(f"/conversations/{conversation_id}/export").status_code == 401 exported = client.get(f"/conversations/{conversation_id}/export", headers=AUTH) From 8ae5c300495209e5e662c3b5ee1355b6ad4585f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:23:05 +0000 Subject: [PATCH 3/3] Give the responsive access requirement its own identifier PROJECT_SPEC listed two different requirements under FR-25, exact-call approval and responsive access. The second row is now FR-26 so each functional requirement has one identifier. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01656XvdqNAVojSPQBtLZAAt --- PROJECT_SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md index 22538b4..f27cc41 100644 --- a/PROJECT_SPEC.md +++ b/PROJECT_SPEC.md @@ -124,7 +124,7 @@ The application is standalone. It is not an Open WebUI fork, wrapper, or plugin. | FR-23 | Tool catalog | Tools default off. When enabled, active schemas and permission metadata come from the runtime registry. | | FR-24 | Agent execution | Model-selected tools run through schema validation, per-tool deadlines, an error budget, an eight-step default ceiling, approval boundaries, and a complete partial transcript. Tool results add an honest `termination` classification without changing existing status values. | | FR-25 | Exact-call approval | An approval-required call is stored in process with canonical arguments, SHA-256 digest, transcript revision, single-use nonce, and a 300-second expiry. Resume accepts only the matching run, call, digest, and unmodified transcript; approval executes those exact arguments without replaying the paused model step, and denial appends an operator-denied tool result before continuing. | -| FR-25 | Responsive access | Chat, History, and Runtime navigation remains usable at mobile widths; motion respects `prefers-reduced-motion`. | +| FR-26 | Responsive access | Chat, History, and Runtime navigation remains usable at mobile widths; motion respects `prefers-reduced-motion`. | ## 5. Context and prompt contract