- Anchor CRED_DIR/KEY_PATH to __file__ (not CWD) in credential_store.py - Fix email PUT: separate password pop from sentinel discard (was fragile or-chain) - Fix email test: always use stored credential, remove password override path - Move integrationResults into system store (was view-local — spec violation) - saveFilePaths/saveDeployConfig write to dedicated error refs, not saveError
394 lines
20 KiB
Vue
394 lines
20 KiB
Vue
<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>
|
|
|
|
<!-- Services section -->
|
|
<section class="form-section">
|
|
<h3>Services</h3>
|
|
<p class="section-note">Port-based status. Start/Stop via Docker Compose.</p>
|
|
<div class="service-grid">
|
|
<div v-for="svc in store.services" :key="svc.name" class="service-card">
|
|
<div class="service-header">
|
|
<span class="service-dot" :class="svc.running ? 'dot-running' : 'dot-stopped'"></span>
|
|
<span class="service-name">{{ svc.name }}</span>
|
|
<span class="service-port">:{{ svc.port }}</span>
|
|
</div>
|
|
<p class="service-note">{{ svc.note }}</p>
|
|
<div class="service-actions">
|
|
<button v-if="!svc.running" @click="store.startService(svc.name)" class="btn-start">Start</button>
|
|
<button v-else @click="store.stopService(svc.name)" class="btn-stop">Stop</button>
|
|
</div>
|
|
<p v-if="store.serviceErrors[svc.name]" class="error">{{ store.serviceErrors[svc.name] }}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Email section -->
|
|
<section class="form-section">
|
|
<h3>Email (IMAP)</h3>
|
|
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
|
|
<div class="field-row">
|
|
<label>IMAP Host</label>
|
|
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Port</label>
|
|
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
|
|
</div>
|
|
<label class="checkbox-row">
|
|
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
|
|
</label>
|
|
<div class="field-row">
|
|
<label>Username</label>
|
|
<input v-model="(store.emailConfig as any).username" type="email" />
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Password / App Password</label>
|
|
<input
|
|
v-model="emailPasswordInput"
|
|
type="password"
|
|
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
|
|
/>
|
|
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Sent Folder</label>
|
|
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Lookback Days</label>
|
|
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
|
|
</div>
|
|
<div class="form-actions">
|
|
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
|
|
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
|
|
</button>
|
|
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
|
|
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
|
|
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
|
|
</span>
|
|
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Integrations -->
|
|
<section class="form-section">
|
|
<h3>Integrations</h3>
|
|
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
|
|
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
|
|
<div class="integration-header">
|
|
<span class="integration-name">{{ integration.name }}</span>
|
|
<div class="integration-badges">
|
|
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
|
|
Requires {{ integration.tier_required }}
|
|
</span>
|
|
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
|
|
{{ integration.connected ? 'Connected' : 'Disconnected' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<!-- Locked state for insufficient tier -->
|
|
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
|
|
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
|
|
</div>
|
|
<!-- Normal state for sufficient tier -->
|
|
<template v-else>
|
|
<div v-if="!integration.connected" class="integration-form">
|
|
<div v-for="field in integration.fields" :key="field.key" class="field-row">
|
|
<label>{{ field.label }}</label>
|
|
<input v-model="integrationInputs[integration.id + ':' + field.key]"
|
|
:type="field.type === 'password' ? 'password' : 'text'" />
|
|
</div>
|
|
<div class="form-actions">
|
|
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
|
|
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
|
|
<span v-if="store.integrationResults[integration.id]" :class="store.integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
|
|
{{ store.integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + store.integrationResults[integration.id].error }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- File Paths -->
|
|
<section class="form-section">
|
|
<h3>File Paths</h3>
|
|
<div class="field-row">
|
|
<label>Documents Directory</label>
|
|
<input v-model="(store.filePaths as any).docs_dir" placeholder="/Library/Documents/JobSearch" />
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Data Directory</label>
|
|
<input v-model="(store.filePaths as any).data_dir" placeholder="data/" />
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Model Directory</label>
|
|
<input v-model="(store.filePaths as any).model_dir" placeholder="/Library/Assets/LLM" />
|
|
</div>
|
|
<div class="form-actions">
|
|
<button @click="store.saveFilePaths()" :disabled="store.filePathsSaving" class="btn-primary">
|
|
{{ store.filePathsSaving ? 'Saving…' : 'Save Paths' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="store.filePathsError" class="error-msg">{{ store.filePathsError }}</p>
|
|
</section>
|
|
|
|
<!-- Deployment / Server -->
|
|
<section class="form-section">
|
|
<h3>Deployment / Server</h3>
|
|
<p class="section-note">Restart required for changes to take effect.</p>
|
|
<div class="field-row">
|
|
<label>Base URL Path</label>
|
|
<input v-model="(store.deployConfig as any).base_url_path" placeholder="/peregrine" />
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Server Host</label>
|
|
<input v-model="(store.deployConfig as any).server_host" placeholder="0.0.0.0" />
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Server Port</label>
|
|
<input v-model.number="(store.deployConfig as any).server_port" type="number" placeholder="8502" />
|
|
</div>
|
|
<div class="form-actions">
|
|
<button @click="store.saveDeployConfig()" :disabled="store.deploySaving" class="btn-primary">
|
|
{{ store.deploySaving ? 'Saving…' : 'Save (requires restart)' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="store.deployError" class="error-msg">{{ store.deployError }}</p>
|
|
</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 { storeToRefs } from 'pinia'
|
|
import { useSystemStore } from '../../stores/settings/system'
|
|
import { useAppConfigStore } from '../../stores/appConfig'
|
|
|
|
const store = useSystemStore()
|
|
const config = useAppConfigStore()
|
|
const { tier } = storeToRefs(config)
|
|
|
|
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
|
|
)
|
|
)
|
|
|
|
const tierOrder = ['free', 'paid', 'premium', 'ultra']
|
|
function meetsRequiredTier(required: string): boolean {
|
|
return tierOrder.indexOf(tier.value) >= tierOrder.indexOf(required || 'free')
|
|
}
|
|
|
|
function dragStart(idx: number) {
|
|
dragIdx.value = idx
|
|
}
|
|
|
|
function dragOver(toFilteredIdx: number) {
|
|
if (dragIdx.value === null || dragIdx.value === toFilteredIdx) return
|
|
const fromId = visibleBackends.value[dragIdx.value].id
|
|
const toId = visibleBackends.value[toFilteredIdx].id
|
|
const arr = [...store.backends]
|
|
const fromFull = arr.findIndex(b => b.id === fromId)
|
|
const toFull = arr.findIndex(b => b.id === toId)
|
|
if (fromFull === -1 || toFull === -1) return
|
|
const [moved] = arr.splice(fromFull, 1)
|
|
arr.splice(toFull, 0, moved)
|
|
store.backends = arr.map((b, i) => ({ ...b, priority: i + 1 }))
|
|
dragIdx.value = toFilteredIdx
|
|
}
|
|
|
|
function drop() {
|
|
dragIdx.value = null
|
|
}
|
|
|
|
async function handleConfirmByok() {
|
|
await store.confirmByok()
|
|
byokConfirmed.value = false
|
|
}
|
|
|
|
const emailTestResult = ref<boolean | null>(null)
|
|
const emailPasswordInput = ref('')
|
|
const integrationInputs = ref<Record<string, string>>({})
|
|
async function handleTestEmail() {
|
|
const result = await store.testEmail()
|
|
emailTestResult.value = result?.ok ?? false
|
|
}
|
|
|
|
async function handleSaveEmail() {
|
|
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
|
|
await store.saveEmailWithPassword(payload)
|
|
}
|
|
|
|
async function handleConnect(id: string) {
|
|
const integration = store.integrations.find(i => i.id === id)
|
|
if (!integration) return
|
|
const credentials: Record<string, string> = {}
|
|
for (const field of integration.fields) {
|
|
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
|
}
|
|
await store.connectIntegration(id, credentials)
|
|
}
|
|
|
|
async function handleTest(id: string) {
|
|
const integration = store.integrations.find(i => i.id === id)
|
|
if (!integration) return
|
|
const credentials: Record<string, string> = {}
|
|
for (const field of integration.fields) {
|
|
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
|
}
|
|
await store.testIntegration(id, credentials)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await store.loadLlm()
|
|
await Promise.all([
|
|
store.loadServices(),
|
|
store.loadEmail(),
|
|
store.loadIntegrations(),
|
|
store.loadFilePaths(),
|
|
store.loadDeployConfig(),
|
|
])
|
|
})
|
|
</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; }
|
|
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
.service-card { 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; padding: 14px; }
|
|
.service-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.dot-running { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
|
|
.dot-stopped { background: #64748b; }
|
|
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
|
|
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; }
|
|
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; }
|
|
.service-actions { display: flex; gap: 6px; }
|
|
.btn-start { padding: 4px 12px; border-radius: 4px; background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); cursor: pointer; font-size: 0.78rem; }
|
|
.btn-stop { padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); cursor: pointer; font-size: 0.78rem; }
|
|
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
|
|
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
|
.field-row input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 7px 10px; font-size: 0.88rem; }
|
|
.field-hint { font-size: 0.72rem; color: var(--color-text-secondary, #64748b); margin-top: 3px; }
|
|
.btn-secondary { 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.88rem; }
|
|
.btn-danger { padding: 6px 14px; border-radius: 6px; background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; }
|
|
.test-ok { color: #22c55e; font-size: 0.85rem; }
|
|
.test-fail { color: #ef4444; font-size: 0.85rem; }
|
|
.integration-card { 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; padding: 16px; margin-bottom: 12px; }
|
|
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
|
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); }
|
|
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
|
|
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
|
|
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
|
|
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
|
|
.tier-badge { font-size: 0.68rem; padding: 2px 7px; border-radius: 8px; background: rgba(245,158,11,0.15); color: #f59e0b; border: 1px solid rgba(245,158,11,0.3); margin-right: 6px; }
|
|
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); }
|
|
.integration-badges { display: flex; align-items: center; gap: 4px; }
|
|
</style>
|