236 lines
9.0 KiB
Vue
236 lines
9.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from "vue";
|
|
import { Check, LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
|
|
import {
|
|
activateLlmProviderProfile,
|
|
createLlmProviderProfile,
|
|
deleteLlmProviderProfile,
|
|
listLlmProviderProfiles,
|
|
updateLlmProviderProfile,
|
|
} from "../api";
|
|
import type { LlmProviderProfile, LlmProviderSettings, LlmProviderType } from "../types";
|
|
|
|
defineProps<{ canAdmin: boolean }>();
|
|
|
|
const profiles = ref<LlmProviderProfile[]>([]);
|
|
const settings = ref<LlmProviderSettings>({ active_profile_id: null, revision: 0, updated_at: null });
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const editingId = ref<string | null>(null);
|
|
const formError = ref("");
|
|
const successMessage = ref("");
|
|
const errorMessage = ref("");
|
|
|
|
const form = reactive({
|
|
name: "",
|
|
provider_type: "openai-compatible" as LlmProviderType,
|
|
model: "",
|
|
base_url: "",
|
|
timeout_seconds: "30",
|
|
api_key: "",
|
|
enabled: true,
|
|
});
|
|
const editingProfile = computed(
|
|
() => profiles.value.find((profile) => profile.id === editingId.value) ?? null,
|
|
);
|
|
|
|
function resetForm() {
|
|
editingId.value = null;
|
|
form.name = "";
|
|
form.provider_type = "openai-compatible";
|
|
form.model = "";
|
|
form.base_url = "";
|
|
form.timeout_seconds = "30";
|
|
form.api_key = "";
|
|
form.enabled = true;
|
|
formError.value = "";
|
|
}
|
|
|
|
function showError(error: unknown, fallback: string) {
|
|
successMessage.value = "";
|
|
errorMessage.value = error instanceof Error ? error.message : fallback;
|
|
}
|
|
|
|
async function refresh() {
|
|
loading.value = true;
|
|
errorMessage.value = "";
|
|
try {
|
|
const response = await listLlmProviderProfiles();
|
|
profiles.value = response.items;
|
|
settings.value = response.settings;
|
|
} catch (error) {
|
|
showError(error, "failed to load LLM providers");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function editProfile(profile: LlmProviderProfile) {
|
|
editingId.value = profile.id;
|
|
form.name = profile.name;
|
|
form.provider_type = profile.provider_type;
|
|
form.model = profile.model;
|
|
form.base_url = profile.base_url ?? "";
|
|
form.timeout_seconds = String(profile.timeout_seconds);
|
|
form.api_key = "";
|
|
form.enabled = profile.enabled;
|
|
formError.value = "";
|
|
successMessage.value = "";
|
|
}
|
|
|
|
function parseTimeout(): number | null {
|
|
const timeout = Number(form.timeout_seconds);
|
|
return Number.isFinite(timeout) && timeout > 0 && timeout <= 120 ? timeout : null;
|
|
}
|
|
|
|
async function saveProfile() {
|
|
formError.value = "";
|
|
successMessage.value = "";
|
|
const timeout = parseTimeout();
|
|
if (!form.name.trim() || !form.model.trim() || timeout === null) {
|
|
formError.value = "name, model, and a timeout between 0 and 120 are required";
|
|
return;
|
|
}
|
|
if (!editingId.value && !form.api_key) {
|
|
formError.value = "an API key is required for a new Provider profile";
|
|
return;
|
|
}
|
|
|
|
saving.value = true;
|
|
try {
|
|
const baseUrl = form.provider_type === "anthropic" ? null : form.base_url.trim() || null;
|
|
if (editingId.value) {
|
|
const existing = profiles.value.find((profile) => profile.id === editingId.value);
|
|
if (!existing) throw new Error("Provider profile no longer exists");
|
|
const payload: Parameters<typeof updateLlmProviderProfile>[1] = {
|
|
name: form.name.trim(),
|
|
provider_type: form.provider_type,
|
|
model: form.model.trim(),
|
|
base_url: baseUrl,
|
|
timeout_seconds: timeout,
|
|
enabled: form.enabled,
|
|
expected_revision: existing.revision,
|
|
};
|
|
if (form.api_key) payload.api_key = form.api_key;
|
|
await updateLlmProviderProfile(editingId.value, payload);
|
|
successMessage.value = "Provider profile saved";
|
|
} else {
|
|
await createLlmProviderProfile({
|
|
name: form.name.trim(),
|
|
provider_type: form.provider_type,
|
|
model: form.model.trim(),
|
|
base_url: baseUrl,
|
|
timeout_seconds: timeout,
|
|
api_key: form.api_key,
|
|
});
|
|
successMessage.value = "Provider profile created";
|
|
}
|
|
resetForm();
|
|
await refresh();
|
|
} catch (error) {
|
|
formError.value = error instanceof Error ? error.message : "failed to save Provider profile";
|
|
} finally {
|
|
form.api_key = "";
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function activate(profile: LlmProviderProfile) {
|
|
saving.value = true;
|
|
errorMessage.value = "";
|
|
try {
|
|
settings.value = await activateLlmProviderProfile(profile.id, settings.value.revision);
|
|
successMessage.value = `${profile.name} is active for Cloud planner requests`;
|
|
await refresh();
|
|
} catch (error) {
|
|
showError(error, "failed to activate Provider profile");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function remove(profile: LlmProviderProfile) {
|
|
if (!window.confirm(`Delete ${profile.name}?`)) return;
|
|
saving.value = true;
|
|
errorMessage.value = "";
|
|
try {
|
|
await deleteLlmProviderProfile(profile.id, profile.revision);
|
|
successMessage.value = "Provider profile deleted";
|
|
if (editingId.value === profile.id) resetForm();
|
|
await refresh();
|
|
} catch (error) {
|
|
showError(error, "failed to delete Provider profile");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(refresh);
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="toolbar">
|
|
<h2>LLM providers</h2>
|
|
<button title="Refresh providers" aria-label="Refresh providers" :disabled="loading" @click="refresh">
|
|
<RefreshCw :size="14" />
|
|
</button>
|
|
<button class="primary" @click="resetForm"><Plus :size="14" /> New provider</button>
|
|
<span v-if="loading" class="muted"><LoaderCircle :size="14" class="loader" /> loading…</span>
|
|
</div>
|
|
|
|
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
|
<div v-if="successMessage" class="notice success">{{ successMessage }}</div>
|
|
|
|
<div class="panel">
|
|
<h3>{{ editingId ? "Edit Provider" : "New Provider" }}</h3>
|
|
<form @submit.prevent="saveProfile">
|
|
<div class="form-grid">
|
|
<label>Name <input v-model="form.name" autocomplete="off" /></label>
|
|
<label>Protocol
|
|
<select v-model="form.provider_type">
|
|
<option value="openai-compatible">OpenAI-compatible</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
</select>
|
|
</label>
|
|
<label>Model <input v-model="form.model" autocomplete="off" /></label>
|
|
<label>Timeout seconds <input v-model="form.timeout_seconds" inputmode="decimal" /></label>
|
|
<label v-if="form.provider_type === 'openai-compatible'" class="field-full">Base URL
|
|
<input v-model="form.base_url" placeholder="Official OpenAI endpoint when blank" autocomplete="url" />
|
|
</label>
|
|
<label class="field-full">{{ editingId ? "Rotate API key" : "API key" }}
|
|
<input v-model="form.api_key" type="password" autocomplete="new-password" />
|
|
</label>
|
|
<label v-if="editingId"><input v-model="form.enabled" type="checkbox" :disabled="editingProfile?.active" /> Enabled</label>
|
|
</div>
|
|
<div v-if="formError" class="notice error" style="margin-top: 12px">{{ formError }}</div>
|
|
<div class="toolbar" style="margin-top: 12px">
|
|
<button type="submit" class="primary" :disabled="saving">{{ editingId ? "Save Provider" : "Create Provider" }}</button>
|
|
<button v-if="editingId" type="button" title="Cancel edit" aria-label="Cancel edit" @click="resetForm"><X :size="14" /></button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<table v-if="profiles.length">
|
|
<thead><tr><th>Name</th><th>Protocol / model</th><th>Endpoint</th><th>State</th><th>Key rotation</th><th></th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="profile in profiles" :key="profile.id">
|
|
<td><strong>{{ profile.name }}</strong></td>
|
|
<td><span class="status-badge queued">{{ profile.provider_type }}</span><br /><span class="dim">{{ profile.model }}</span></td>
|
|
<td class="dim">{{ profile.base_url ?? "Official endpoint" }}</td>
|
|
<td><span :class="profile.active ? 'status-badge done' : profile.enabled ? 'status-badge idle' : 'status-badge failed'">{{ profile.active ? "active" : profile.enabled ? "ready" : "disabled" }}</span></td>
|
|
<td class="dim">{{ new Date(profile.key_last_rotated_at).toLocaleString() }}</td>
|
|
<td class="provider-actions">
|
|
<button v-if="!profile.active" class="icon-button" title="Activate Provider" :aria-label="`Activate ${profile.name}`" :disabled="saving || !profile.enabled" @click="activate(profile)"><Check :size="14" /></button>
|
|
<button class="icon-button" title="Edit Provider" :aria-label="`Edit ${profile.name}`" :disabled="saving" @click="editProfile(profile)"><Pencil :size="14" /></button>
|
|
<button class="icon-button danger" title="Delete Provider" :aria-label="`Delete ${profile.name}`" :disabled="saving || profile.active" @click="remove(profile)"><Trash2 :size="14" /></button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-else class="muted">No Provider profiles configured.</p>
|
|
</div>
|
|
</div>
|
|
</template>
|