Remove local on-device model support and related IPC commands, consolidating commit-generation to cloud providers and simplifying the AI crate surface. Local-specific types, generation profiles, caching, and the local prompt builder were removed while message sanitization and diff-echo detection were preserved. Also harden repository handling and runtime: unborn HEADs are handled gracefully so empty repos still report files, Git LFS sync is skipped for repositories without commits, and tokio runtime features were enabled. - Remove local model engine, load/status commands, and local profile code - Handle unborn HEAD and skip LFS sync for repos without commits - Enable tokio runtime features and route AI generation to cloud only
241 lines
8.5 KiB
Svelte
241 lines
8.5 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { Bot, Check, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
|
|
import { credDelete, credLoad, credSave } from "../git";
|
|
import type { AiSettings, CommitAiProvider } from "../types";
|
|
|
|
interface Props {
|
|
settings: AiSettings;
|
|
onSave: (settings: AiSettings) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let { settings, onSave, onClose }: Props = $props();
|
|
|
|
type CloudProvider = CommitAiProvider;
|
|
|
|
const CRED_KEYS: Record<CloudProvider, string> = {
|
|
openai: "ai:openai",
|
|
anthropic: "ai:anthropic",
|
|
custom: "ai:custom",
|
|
};
|
|
|
|
let provider = $state<CommitAiProvider>("openai");
|
|
let openaiModel = $state("");
|
|
let anthropicModel = $state("");
|
|
let customBaseUrl = $state("");
|
|
let customModel = $state("");
|
|
|
|
let openaiApiKey = $state("");
|
|
let anthropicApiKey = $state("");
|
|
let customApiKey = $state("");
|
|
let showKey = $state(false);
|
|
let loadingKeys = $state(true);
|
|
let saving = $state(false);
|
|
let error = $state("");
|
|
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
$effect(() => {
|
|
provider = settings.provider;
|
|
openaiModel = settings.openaiModel;
|
|
anthropicModel = settings.anthropicModel;
|
|
customBaseUrl = settings.customBaseUrl;
|
|
customModel = settings.customModel;
|
|
});
|
|
|
|
$effect(() => {
|
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
|
const currentError = error;
|
|
if (currentError) {
|
|
errorHideTimer = setTimeout(() => {
|
|
if (error === currentError) error = "";
|
|
}, 6000);
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
(async () => {
|
|
try {
|
|
const [openai, anthropic, custom] = await Promise.all([
|
|
credLoad(CRED_KEYS.openai),
|
|
credLoad(CRED_KEYS.anthropic),
|
|
credLoad(CRED_KEYS.custom),
|
|
]);
|
|
openaiApiKey = openai?.password ?? "";
|
|
anthropicApiKey = anthropic?.password ?? "";
|
|
customApiKey = custom?.password ?? "";
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
loadingKeys = false;
|
|
}
|
|
})();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
|
});
|
|
|
|
async function persistKey(target: CloudProvider, value: string) {
|
|
const key = CRED_KEYS[target];
|
|
const trimmed = value.trim();
|
|
if (trimmed) {
|
|
await credSave(key, "api-key", trimmed);
|
|
} else {
|
|
await credDelete(key);
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
saving = true;
|
|
error = "";
|
|
try {
|
|
await Promise.all([
|
|
persistKey("openai", openaiApiKey),
|
|
persistKey("anthropic", anthropicApiKey),
|
|
persistKey("custom", customApiKey),
|
|
]);
|
|
onSave({
|
|
provider,
|
|
openaiModel: openaiModel.trim() || "gpt-4o-mini",
|
|
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
|
|
customBaseUrl: customBaseUrl.trim(),
|
|
customModel: customModel.trim(),
|
|
});
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<div
|
|
class="dialog-backdrop"
|
|
role="presentation"
|
|
>
|
|
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
|
<header class="dialog-header">
|
|
<div>
|
|
<span class="eyebrow">Commit AI</span>
|
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">AI settings</h2>
|
|
</div>
|
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
|
<X size={18} aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
|
|
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
|
|
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
|
|
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
|
<Bot size={16} aria-hidden="true" />
|
|
OpenAI
|
|
</button>
|
|
<button type="button" class="ai-provider-option" class:active={provider === "anthropic"} onclick={() => { provider = "anthropic"; }}>
|
|
<Bot size={16} aria-hidden="true" />
|
|
Anthropic (Claude)
|
|
</button>
|
|
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
|
|
<Globe size={16} aria-hidden="true" />
|
|
Custom endpoint
|
|
</button>
|
|
</div>
|
|
|
|
{#if provider === "openai"}
|
|
<label class="cred-field">
|
|
<span class="cred-field-label">Model</span>
|
|
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
|
</label>
|
|
<div class="cred-field">
|
|
<span class="cred-field-label">API key</span>
|
|
<div class="cred-input">
|
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
|
<input
|
|
type={showKey ? "text" : "password"}
|
|
bind:value={openaiApiKey}
|
|
placeholder="sk-..."
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
disabled={loadingKeys}
|
|
/>
|
|
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
|
|
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else if provider === "anthropic"}
|
|
<label class="cred-field">
|
|
<span class="cred-field-label">Model</span>
|
|
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
|
|
</label>
|
|
<div class="cred-field">
|
|
<span class="cred-field-label">API key</span>
|
|
<div class="cred-input">
|
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
|
<input
|
|
type={showKey ? "text" : "password"}
|
|
bind:value={anthropicApiKey}
|
|
placeholder="sk-ant-..."
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
disabled={loadingKeys}
|
|
/>
|
|
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
|
|
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<label class="cred-field">
|
|
<span class="cred-field-label">Endpoint URL</span>
|
|
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
|
|
</label>
|
|
<label class="cred-field">
|
|
<span class="cred-field-label">Model</span>
|
|
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
|
|
</label>
|
|
<div class="cred-field">
|
|
<span class="cred-field-label">API key (optional)</span>
|
|
<div class="cred-input">
|
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
|
<input
|
|
type={showKey ? "text" : "password"}
|
|
bind:value={customApiKey}
|
|
placeholder="Optional"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
disabled={loadingKeys}
|
|
/>
|
|
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
|
|
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="cred-token-hint">
|
|
<Globe size={13} aria-hidden="true" />
|
|
<span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if error}
|
|
<p class="commit-block-reason">{error}</p>
|
|
{/if}
|
|
|
|
<div class="new-branch-actions">
|
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={saving}>
|
|
Cancel
|
|
</button>
|
|
<button class="btn-primary" type="submit" disabled={saving || loadingKeys}>
|
|
{#if saving}
|
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
{:else}
|
|
<Check size={16} aria-hidden="true" />
|
|
{/if}
|
|
Save
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|