diff --git a/dev-api.py b/dev-api.py index 1e3b968..d74c738 100644 --- a/dev-api.py +++ b/dev-api.py @@ -1142,3 +1142,50 @@ def suggest_search(body: dict): return {"suggestions": []} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — LLM Backends + BYOK endpoints ───────────────────────── + +class ByokAckPayload(BaseModel): + backends: List[str] = [] + +LLM_CONFIG_PATH = Path("config/llm.yaml") + +@app.get("/api/settings/system/llm") +def get_llm_config(): + try: + user = load_user_profile(_user_yaml_path()) + backends = [] + if LLM_CONFIG_PATH.exists(): + with open(LLM_CONFIG_PATH) as f: + data = yaml.safe_load(f) or {} + backends = data.get("backends", []) + return {"backends": backends, "byok_acknowledged": user.get("byok_acknowledged_backends", [])} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.put("/api/settings/system/llm") +def save_llm_config(payload: dict): + try: + data = {} + if LLM_CONFIG_PATH.exists(): + with open(LLM_CONFIG_PATH) as f: + data = yaml.safe_load(f) or {} + data["backends"] = payload.get("backends", []) + LLM_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LLM_CONFIG_PATH, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/settings/system/llm/byok-ack") +def byok_ack(payload: ByokAckPayload): + try: + user = load_user_profile(_user_yaml_path()) + existing = user.get("byok_acknowledged_backends", []) + user["byok_acknowledged_backends"] = list(set(existing + payload.backends)) + save_user_profile(_user_yaml_path(), user) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/web/src/stores/settings/system.test.ts b/web/src/stores/settings/system.test.ts new file mode 100644 index 0000000..c4ececc --- /dev/null +++ b/web/src/stores/settings/system.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useSystemStore } from './system' + +vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('useSystemStore — BYOK gate', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() }) + + it('save() proceeds without modal when no cloud backends enabled', async () => { + mockFetch.mockResolvedValue({ data: { ok: true }, error: null }) + const store = useSystemStore() + store.backends = [{ id: 'ollama', enabled: true, priority: 1 }] + store.byokAcknowledged = [] + await store.trySave() + expect(store.byokPending).toHaveLength(0) + expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything()) + }) + + it('save() sets byokPending when new cloud backend enabled', async () => { + const store = useSystemStore() + store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }] + store.byokAcknowledged = [] + await store.trySave() + expect(store.byokPending).toContain('anthropic') + expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything()) + }) + + it('save() skips modal for already-acknowledged backends', async () => { + mockFetch.mockResolvedValue({ data: { ok: true }, error: null }) + const store = useSystemStore() + store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }] + store.byokAcknowledged = ['anthropic'] + await store.trySave() + expect(store.byokPending).toHaveLength(0) + }) + + it('confirmByok() saves acknowledgment then commits LLM config', async () => { + mockFetch.mockResolvedValue({ data: { ok: true }, error: null }) + const store = useSystemStore() + store.byokPending = ['anthropic'] + store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }] + await store.confirmByok() + expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm/byok-ack', expect.anything()) + expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything()) + }) + + it('cancelByok() clears pending and restores backends to pre-save state', async () => { + mockFetch.mockResolvedValue({ data: { ok: true }, error: null }) + const store = useSystemStore() + const original = [{ id: 'ollama', enabled: true, priority: 1 }] + store.backends = [...original] + await store.trySave() // captures snapshot, commits (no cloud backends) + store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }] + store.byokPending = ['anthropic'] + store.cancelByok() + expect(store.byokPending).toHaveLength(0) + expect(store.backends).toEqual(original) + }) +}) diff --git a/web/src/stores/settings/system.ts b/web/src/stores/settings/system.ts new file mode 100644 index 0000000..f3fea40 --- /dev/null +++ b/web/src/stores/settings/system.ts @@ -0,0 +1,73 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../../composables/useApi' + +const CLOUD_BACKEND_IDS = ['anthropic', 'openai'] + +export interface Backend { id: string; enabled: boolean; priority: number } + +export const useSystemStore = defineStore('settings/system', () => { + const backends = ref([]) + const byokAcknowledged = ref([]) + const byokPending = ref([]) + // Private snapshot — NOT in return(). Closure-level only. + let _preSaveSnapshot: Backend[] = [] + const saving = ref(false) + const saveError = ref(null) + const loadError = ref(null) + + async function loadLlm() { + loadError.value = null + const { data, error } = await useApiFetch<{ backends: Backend[]; byok_acknowledged: string[] }>('/api/settings/system/llm') + if (error) { loadError.value = 'Failed to load LLM config'; return } + if (!data) return + backends.value = data.backends ?? [] + byokAcknowledged.value = data.byok_acknowledged ?? [] + } + + async function trySave() { + _preSaveSnapshot = JSON.parse(JSON.stringify(backends.value)) + const newlyEnabled = backends.value + .filter(b => CLOUD_BACKEND_IDS.includes(b.id) && b.enabled) + .map(b => b.id) + .filter(id => !byokAcknowledged.value.includes(id)) + if (newlyEnabled.length > 0) { + byokPending.value = newlyEnabled + return // modal takes over + } + await _commitSave() + } + + async function confirmByok() { + saving.value = true + const { error } = await useApiFetch('/api/settings/system/llm/byok-ack', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backends: byokPending.value }), + }) + if (!error) byokAcknowledged.value = [...byokAcknowledged.value, ...byokPending.value] + byokPending.value = [] + await _commitSave() + } + + function cancelByok() { + backends.value = JSON.parse(JSON.stringify(_preSaveSnapshot)) + byokPending.value = [] + _preSaveSnapshot = [] + } + + async function _commitSave() { + saving.value = true + saveError.value = null + const { error } = await useApiFetch('/api/settings/system/llm', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backends: backends.value }), + }) + saving.value = false + if (error) saveError.value = 'Save failed — please try again.' + } + + // Services, email, integrations added in Task 6 + return { backends, byokAcknowledged, byokPending, saving, saveError, loadError, loadLlm, trySave, confirmByok, cancelByok } +}) diff --git a/web/src/views/settings/SystemSettingsView.vue b/web/src/views/settings/SystemSettingsView.vue new file mode 100644 index 0000000..e0e4433 --- /dev/null +++ b/web/src/views/settings/SystemSettingsView.vue @@ -0,0 +1,154 @@ + + + + +