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:
@@ -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>
|
||||
Reference in New Issue
Block a user