Introduce a generic ConfirmDialog and a promise-based requestConfirmation API in App.svelte so callers can await user responses instead of using window.confirm. Provide helper builders (branchDeleteConfirmRequest, discardConfirmRequest) to create dialog content for common cases. Many call sites were switched to use requestConfirmation and now render the in-app ConfirmDialog; the previous specialized confirm components (BranchDeleteConfirmDialog, DiscardConfirmDialog) were removed. Add lightweight i18n support (setLanguage, t()) and new messages/i18n modules, and replace hardcoded English strings in several components (e.g. AiSettingsPage, BlameDialog and many confirmation prompts) with translated keys. Summary of effects: - Replaces native window.confirm with awaitable in-app ConfirmDialog dialogs. - Centralizes confirmation UI and content construction in App.svelte. - Adds i18n plumbing and updates UI text to use t(). - Removes two specialized confirm dialog components and adds src/lib/components/ConfirmDialog.svelte.
219 lines
7.8 KiB
Svelte
219 lines
7.8 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { Bot, Eye, EyeOff, Globe, Key } from "@lucide/svelte";
|
|
import { credDelete, credLoad, credSave } from "../git";
|
|
import type { AiSettings, CommitAiProvider } from "../types";
|
|
import { t } from "../i18n.svelte";
|
|
|
|
interface Props {
|
|
settings: AiSettings;
|
|
|
|
}
|
|
|
|
let { settings }: 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 originalKeys = { openai: "", anthropic: "", custom: "" };
|
|
let keysLoaded = false;
|
|
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 ?? "";
|
|
originalKeys = { openai: openaiApiKey, anthropic: anthropicApiKey, custom: customApiKey };
|
|
keysLoaded = true;
|
|
} 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) {
|
|
if (value === originalKeys[target]) return;
|
|
if (!keysLoaded) throw new Error(t("ai.keysNotLoaded"));
|
|
const key = CRED_KEYS[target];
|
|
const trimmed = value.trim();
|
|
if (trimmed) {
|
|
await credSave(key, "api-key", trimmed);
|
|
} else {
|
|
await credDelete(key);
|
|
}
|
|
}
|
|
|
|
export async function saveSettings(): Promise<AiSettings> {
|
|
if (loadingKeys) throw new Error(t("ai.waitForSettings"));
|
|
saving = true;
|
|
error = "";
|
|
try {
|
|
await Promise.all([
|
|
persistKey("openai", openaiApiKey),
|
|
persistKey("anthropic", anthropicApiKey),
|
|
persistKey("custom", customApiKey),
|
|
]);
|
|
return {
|
|
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);
|
|
throw err;
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<div class="ai-settings-form">
|
|
<div class="ai-provider-options" role="radiogroup" aria-label={t("ai.providerLabel")}>
|
|
<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" />
|
|
{t("ai.custom")}
|
|
</button>
|
|
</div>
|
|
|
|
{#if provider === "openai"}
|
|
<label class="cred-field">
|
|
<span class="cred-field-label">{t("ai.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">{t("ai.apiKey")}</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">{t("ai.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">{t("ai.apiKey")}</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">{t("ai.endpointUrl")}</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">{t("ai.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">{t("ai.apiKeyOptional")}</span>
|
|
<div class="cred-input">
|
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
|
<input
|
|
type={showKey ? "text" : "password"}
|
|
bind:value={customApiKey}
|
|
placeholder={t("ai.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>{t("ai.customHint")}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if error}
|
|
<p class="commit-block-reason">{error}</p>
|
|
{/if}
|
|
|
|
</div>
|