feat(settings): System tab — LLM backends, BYOK gate, store + view
This commit is contained in:
parent
91874a176c
commit
0d17b20831
4 changed files with 336 additions and 0 deletions
47
dev-api.py
47
dev-api.py
|
|
@ -1142,3 +1142,50 @@ def suggest_search(body: dict):
|
||||||
return {"suggestions": []}
|
return {"suggestions": []}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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))
|
||||||
|
|
|
||||||
62
web/src/stores/settings/system.test.ts
Normal file
62
web/src/stores/settings/system.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
73
web/src/stores/settings/system.ts
Normal file
73
web/src/stores/settings/system.ts
Normal file
|
|
@ -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<Backend[]>([])
|
||||||
|
const byokAcknowledged = ref<string[]>([])
|
||||||
|
const byokPending = ref<string[]>([])
|
||||||
|
// Private snapshot — NOT in return(). Closure-level only.
|
||||||
|
let _preSaveSnapshot: Backend[] = []
|
||||||
|
const saving = ref(false)
|
||||||
|
const saveError = ref<string | null>(null)
|
||||||
|
const loadError = ref<string | null>(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 }
|
||||||
|
})
|
||||||
154
web/src/views/settings/SystemSettingsView.vue
Normal file
154
web/src/views/settings/SystemSettingsView.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<template>
|
||||||
|
<div class="system-settings">
|
||||||
|
<h2>System Settings</h2>
|
||||||
|
<p class="tab-note">This tab is only available in self-hosted mode.</p>
|
||||||
|
|
||||||
|
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
|
||||||
|
|
||||||
|
<!-- LLM Backends -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>LLM Backends</h3>
|
||||||
|
<p class="section-note">Drag to reorder. Higher position = higher priority in the fallback chain.</p>
|
||||||
|
|
||||||
|
<div class="backend-list">
|
||||||
|
<div
|
||||||
|
v-for="(backend, idx) in visibleBackends"
|
||||||
|
:key="backend.id"
|
||||||
|
class="backend-card"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="dragStart(idx)"
|
||||||
|
@dragover.prevent="dragOver(idx)"
|
||||||
|
@drop="drop"
|
||||||
|
>
|
||||||
|
<span class="drag-handle" aria-hidden="true">⠿</span>
|
||||||
|
<span class="priority-badge">{{ idx + 1 }}</span>
|
||||||
|
<span class="backend-id">{{ backend.id }}</span>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="backend.enabled"
|
||||||
|
@change="store.backends = store.backends.map(b =>
|
||||||
|
b.id === backend.id ? { ...b, enabled: !b.enabled } : b
|
||||||
|
)"
|
||||||
|
/>
|
||||||
|
<span class="toggle-text">{{ backend.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.trySave()" :disabled="store.saving" class="btn-primary">
|
||||||
|
{{ store.saving ? 'Saving…' : 'Save Backends' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BYOK Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="byok-title">
|
||||||
|
<h3 id="byok-title">⚠️ Cloud LLM Key Required</h3>
|
||||||
|
<p>You are enabling the following cloud backends:</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="b in store.byokPending" :key="b">{{ b }}</li>
|
||||||
|
</ul>
|
||||||
|
<p class="byok-warning">
|
||||||
|
These services require your own API key. Your requests and data will be
|
||||||
|
sent to these third-party providers. Costs will be charged to your account.
|
||||||
|
</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="byokConfirmed" />
|
||||||
|
I understand and have configured my API key in <code>config/llm.yaml</code>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="store.cancelByok()" class="btn-cancel">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleConfirmByok"
|
||||||
|
:disabled="!byokConfirmed || store.saving"
|
||||||
|
class="btn-primary"
|
||||||
|
>{{ store.saving ? 'Saving…' : 'Save with Cloud LLM' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useSystemStore } from '../../stores/settings/system'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const store = useSystemStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
|
const byokConfirmed = ref(false)
|
||||||
|
const dragIdx = ref<number | null>(null)
|
||||||
|
|
||||||
|
const CONTRACTED_ONLY = ['claude-code', 'copilot']
|
||||||
|
|
||||||
|
const visibleBackends = computed(() =>
|
||||||
|
store.backends.filter(b =>
|
||||||
|
!CONTRACTED_ONLY.includes(b.id) || config.contractedClient
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
function dragStart(idx: number) {
|
||||||
|
dragIdx.value = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(idx: number) {
|
||||||
|
if (dragIdx.value === null || dragIdx.value === idx) return
|
||||||
|
// Reorder store.backends (immutable swap)
|
||||||
|
const arr = [...store.backends]
|
||||||
|
const [moved] = arr.splice(dragIdx.value, 1)
|
||||||
|
arr.splice(idx, 0, moved)
|
||||||
|
store.backends = arr
|
||||||
|
// Update priorities
|
||||||
|
store.backends = store.backends.map((b, i) => ({ ...b, priority: i + 1 }))
|
||||||
|
dragIdx.value = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop() {
|
||||||
|
dragIdx.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmByok() {
|
||||||
|
await store.confirmByok()
|
||||||
|
byokConfirmed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => store.loadLlm())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
|
||||||
|
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); }
|
||||||
|
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
|
||||||
|
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 14px; }
|
||||||
|
.backend-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
||||||
|
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; cursor: grab; user-select: none; }
|
||||||
|
.backend-card:active { cursor: grabbing; }
|
||||||
|
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); }
|
||||||
|
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: rgba(124,58,237,0.2); color: var(--color-accent, #a78bfa); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.form-actions { display: flex; align-items: center; gap: var(--space-4, 24px); }
|
||||||
|
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.9rem; }
|
||||||
|
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
|
||||||
|
.error { color: #ef4444; font-size: 0.82rem; }
|
||||||
|
/* BYOK Modal */
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||||
|
.modal-card { background: var(--color-surface-1, #1e293b); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
|
||||||
|
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 12px; }
|
||||||
|
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
|
||||||
|
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue