Files
agentic-mobile-control/cloud-console/src/views/LlmProvidersView.vue
T
2026-07-14 00:31:47 +08:00

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>