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 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/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_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) 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();")