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": []}
|
||||
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))
|
||||
|
|
|
|||
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