feat(ai): generate PR drafts and consolidate AI settings UI

Add pull request draft generation to the commit_ai crate and expose it
via a new Tauri command. The backend builds a branch-range context from
published remote-tracking refs only, calls the chosen AI provider, and
parses a JSON {"title","description"} draft (with validation). Also
register the command in the app and add unit tests for parsing and the
branch-context behavior.

Consolidate AI settings in the frontend by renaming the dialog to an
AiSettingsPage and integrating AI options into the main AppSettings
dialog. Persisted AI preferences are merged with existing localStorage
rather than replacing it, and the settings UI now supports opening the
app settings to a specific initial page ("integrations" or "ai").

Other changes:
- Replace the commit-message system prompt used by build_messages with
  the updated, more detailed guidance text.
This commit is contained in:
2026-09-11 22:34:21 +02:00
parent 007dc99447
commit 4b5de5a88b
10 changed files with 273 additions and 102 deletions
+217
View File
@@ -0,0 +1,217 @@
<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";
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("API keys could not be loaded. Existing credentials have been preserved.");
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("Please wait for AI settings to load.");
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="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>