313 lines
9.0 KiB
Svelte
313 lines
9.0 KiB
Svelte
<!-- [DEF:ProviderConfig:Component] -->
|
|
<!--
|
|
@TIER: STANDARD
|
|
@PURPOSE: UI form for managing LLM provider configurations.
|
|
@LAYER: UI
|
|
@RELATION: DEPENDS_ON -> backend/src/api/routes/llm.py
|
|
-->
|
|
|
|
<script>
|
|
import { onMount } from "svelte";
|
|
import { t } from "../../lib/i18n";
|
|
import { requestApi } from "../../lib/api";
|
|
|
|
/** @type {Array} */
|
|
let { providers = [], onSave = () => {} } = $props();
|
|
|
|
let editingProvider = null;
|
|
let showForm = false;
|
|
|
|
let formData = {
|
|
name: "",
|
|
provider_type: "openai",
|
|
base_url: "https://api.openai.com/v1",
|
|
api_key: "",
|
|
default_model: "gpt-4o",
|
|
is_active: true,
|
|
};
|
|
|
|
let testStatus = { type: "", message: "" };
|
|
let isTesting = false;
|
|
|
|
function resetForm() {
|
|
formData = {
|
|
name: "",
|
|
provider_type: "openai",
|
|
base_url: "https://api.openai.com/v1",
|
|
api_key: "",
|
|
default_model: "gpt-4o",
|
|
is_active: true,
|
|
};
|
|
editingProvider = null;
|
|
testStatus = { type: "", message: "" };
|
|
}
|
|
|
|
function handleEdit(provider) {
|
|
editingProvider = provider;
|
|
formData = { ...provider, api_key: "" }; // Don't populate key for security
|
|
showForm = true;
|
|
}
|
|
|
|
async function testConnection() {
|
|
console.log("[ProviderConfig][Action] Testing connection", formData);
|
|
isTesting = true;
|
|
testStatus = { type: "info", message: $t.llm.testing };
|
|
|
|
try {
|
|
const endpoint = editingProvider
|
|
? `/llm/providers/${editingProvider.id}/test`
|
|
: "/llm/providers/test";
|
|
const result = await requestApi(endpoint, "POST", formData);
|
|
|
|
if (result.success) {
|
|
testStatus = { type: "success", message: $t.llm.connection_success };
|
|
} else {
|
|
testStatus = {
|
|
type: "error",
|
|
message: $t.llm.connection_failed.replace(
|
|
"{error}",
|
|
result.error || "Unknown error",
|
|
),
|
|
};
|
|
}
|
|
} catch (err) {
|
|
testStatus = {
|
|
type: "error",
|
|
message: $t.llm.connection_failed.replace("{error}", err.message),
|
|
};
|
|
} finally {
|
|
isTesting = false;
|
|
}
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
console.log("[ProviderConfig][Action] Submitting provider config");
|
|
const method = editingProvider ? "PUT" : "POST";
|
|
const endpoint = editingProvider
|
|
? `/llm/providers/${editingProvider.id}`
|
|
: "/llm/providers";
|
|
|
|
// When editing, only include api_key if user entered a new one
|
|
const submitData = { ...formData };
|
|
if (editingProvider && !submitData.api_key) {
|
|
// If editing and api_key is empty, don't send it (backend will keep existing)
|
|
delete submitData.api_key;
|
|
}
|
|
|
|
try {
|
|
await requestApi(endpoint, method, submitData);
|
|
showForm = false;
|
|
resetForm();
|
|
onSave();
|
|
} catch (err) {
|
|
alert(`Error: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function toggleActive(provider) {
|
|
try {
|
|
await requestApi(`/llm/providers/${provider.id}`, "PUT", {
|
|
...provider,
|
|
is_active: !provider.is_active,
|
|
});
|
|
onSave();
|
|
} catch (err) {
|
|
console.error("Failed to toggle status", err);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="p-4">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-bold">{$t.llm.providers_title}</h2>
|
|
<button
|
|
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
|
|
on:click={() => {
|
|
resetForm();
|
|
showForm = true;
|
|
}}
|
|
>
|
|
{$t.llm.add_provider}
|
|
</button>
|
|
</div>
|
|
|
|
{#if showForm}
|
|
<div
|
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
>
|
|
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-md">
|
|
<h3 class="text-lg font-semibold mb-4">
|
|
{editingProvider ? $t.llm.edit_provider : $t.llm.new_provider}
|
|
</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label
|
|
for="provider-name"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>{$t.llm.name}</label
|
|
>
|
|
<input
|
|
id="provider-name"
|
|
type="text"
|
|
bind:value={formData.name}
|
|
class="mt-1 block w-full border rounded-md p-2"
|
|
placeholder="e.g. My OpenAI"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="provider-type"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>{$t.llm.type}</label
|
|
>
|
|
<select
|
|
id="provider-type"
|
|
bind:value={formData.provider_type}
|
|
class="mt-1 block w-full border rounded-md p-2"
|
|
>
|
|
<option value="openai">OpenAI</option>
|
|
<option value="openrouter">OpenRouter</option>
|
|
<option value="kilo">Kilo</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="provider-base-url"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>{$t.llm.base_url}</label
|
|
>
|
|
<input
|
|
id="provider-base-url"
|
|
type="text"
|
|
bind:value={formData.base_url}
|
|
class="mt-1 block w-full border rounded-md p-2"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="provider-api-key"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>{$t.llm.api_key}</label
|
|
>
|
|
<input
|
|
id="provider-api-key"
|
|
type="password"
|
|
bind:value={formData.api_key}
|
|
class="mt-1 block w-full border rounded-md p-2"
|
|
placeholder={editingProvider ? "••••••••" : "sk-..."}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="provider-default-model"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>{$t.llm.default_model}</label
|
|
>
|
|
<input
|
|
id="provider-default-model"
|
|
type="text"
|
|
bind:value={formData.default_model}
|
|
class="mt-1 block w-full border rounded-md p-2"
|
|
placeholder="gpt-4o"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex items-center">
|
|
<input
|
|
id="provider-active"
|
|
type="checkbox"
|
|
bind:checked={formData.is_active}
|
|
class="mr-2"
|
|
/>
|
|
<label
|
|
for="provider-active"
|
|
class="text-sm font-medium text-gray-700">{$t.llm.active}</label
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
{#if testStatus.message}
|
|
<div
|
|
class={`mt-4 p-2 rounded text-sm ${testStatus.type === "success" ? "bg-green-100 text-green-800" : testStatus.type === "error" ? "bg-red-100 text-red-800" : "bg-blue-100 text-blue-800"}`}
|
|
>
|
|
{testStatus.message}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mt-6 flex justify-between gap-2">
|
|
<button
|
|
class="px-4 py-2 border rounded hover:bg-gray-50 flex-1"
|
|
on:click={() => {
|
|
showForm = false;
|
|
}}
|
|
>
|
|
{$t.llm.cancel}
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 flex-1"
|
|
disabled={isTesting}
|
|
on:click={testConnection}
|
|
>
|
|
{isTesting ? $t.llm.testing : $t.llm.test}
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex-1"
|
|
on:click={handleSubmit}
|
|
>
|
|
{$t.llm.save}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="grid gap-4">
|
|
{#each providers as provider}
|
|
<div
|
|
class="border rounded-lg p-4 flex justify-between items-center bg-white shadow-sm"
|
|
>
|
|
<div>
|
|
<div class="font-bold flex items-center gap-2">
|
|
{provider.name}
|
|
<span
|
|
class={`text-xs px-2 py-0.5 rounded-full ${provider.is_active ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}`}
|
|
>
|
|
{provider.is_active ? $t.llm.active : "Inactive"}
|
|
</span>
|
|
</div>
|
|
<div class="text-sm text-gray-500">
|
|
{provider.provider_type} • {provider.default_model}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="text-sm text-blue-600 hover:underline"
|
|
on:click={() => handleEdit(provider)}
|
|
>
|
|
{$t.common.edit}
|
|
</button>
|
|
<button
|
|
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
|
|
on:click={() => toggleActive(provider)}
|
|
>
|
|
{provider.is_active ? "Deactivate" : "Activate"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg"
|
|
>
|
|
{$t.llm.no_providers}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- [/DEF:ProviderConfig:Component] -->
|