Files
ss-tools/frontend/src/components/llm/ProviderConfig.svelte
2026-02-20 10:41:15 +03:00

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] -->