feat(ai): add cloud providers and local model selection
Add OpenAI-compatible, Anthropic, and custom endpoint support while
keeping the local model path intact. The UI now lets users choose the
provider and local model, and staged diffs are prepared more carefully
so generated commit messages stay focused and usable.
- src-tauri/crates/commit_ai/*
- Add HTTP-based generators for OpenAI, Anthropic, and custom APIs.
- Introduce shared request/response handling and message sanitizing.
- Expand prompt building to require a body and trim long diffs safely.
- Expose selectable local model metadata and loading by model ID.
- src-tauri/src/git.rs
- Add commands for listing local models and loading them in background.
- Route generation by provider and include staged file lists in prompts.
- Exclude noisy lockfiles from detailed staged diffs.
- src-tauri/src/main.rs
- Wire the new AI commands into the Tauri app setup.
- src/lib/components/*
- Add an AI settings dialog and update the commit panel for provider
and model selection.
- src/lib/git.ts, src/lib/types.ts, src/App.svelte, src/app.css
- Extend frontend state, types, and styling for AI provider settings.
- src-tauri/Cargo.lock, src-tauri/crates/commit_ai/Cargo.toml
- Add reqwest and serde_json for cloud API requests.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
|
||||
import { credDelete, credLoad, credSave } from "../git";
|
||||
import type { AiSettings, CommitAiProvider, LocalModelOption } from "../types";
|
||||
|
||||
interface Props {
|
||||
settings: AiSettings;
|
||||
localModels: LocalModelOption[];
|
||||
onSave: (settings: AiSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { settings, localModels = [], onSave, onClose }: Props = $props();
|
||||
|
||||
type CloudProvider = Exclude<CommitAiProvider, "local">;
|
||||
|
||||
const CRED_KEYS: Record<CloudProvider, string> = {
|
||||
openai: "ai:openai",
|
||||
anthropic: "ai:anthropic",
|
||||
custom: "ai:custom",
|
||||
};
|
||||
|
||||
let provider = $state<CommitAiProvider>("local");
|
||||
let localModelId = $state("");
|
||||
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("");
|
||||
|
||||
$effect(() => {
|
||||
provider = settings.provider;
|
||||
localModelId = settings.localModelId;
|
||||
openaiModel = settings.openaiModel;
|
||||
anthropicModel = settings.anthropicModel;
|
||||
customBaseUrl = settings.customBaseUrl;
|
||||
customModel = settings.customModel;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
async function persistKey(target: CloudProvider, value: string) {
|
||||
const key = CRED_KEYS[target];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
await credSave(key, "api-key", trimmed, null);
|
||||
} 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,
|
||||
localModelId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
|
||||
}
|
||||
|
||||
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<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 === "local"} onclick={() => { provider = "local"; }}>
|
||||
<Cpu size={16} aria-hidden="true" />
|
||||
Local AI
|
||||
</button>
|
||||
<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 === "local"}
|
||||
<label class="cred-field">
|
||||
<span class="cred-field-label">Model</span>
|
||||
<select bind:value={localModelId}>
|
||||
{#each localModels as option (option.id)}
|
||||
<option value={option.id}>{option.label} — {formatSize(option.approx_size_mb)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="cred-token-hint">
|
||||
<AlertCircle size={13} aria-hidden="true" />
|
||||
<span>
|
||||
Beim Wechsel wird das Modell{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
|
||||
im Hintergrund heruntergeladen — je nach Internetverbindung kann das mehrere Minuten dauern.
|
||||
Danach bleibt es lokal zwischengespeichert und lädt beim nächsten Start sofort.
|
||||
</span>
|
||||
</div>
|
||||
{:else 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>Für lokale OpenAI-kompatible Server wie Ollama oder LM Studio. Die Basis-URL sollte auf /v1 enden.</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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle, Sparkles } from "@lucide/svelte";
|
||||
import type { CommitAiPhase } from "../types";
|
||||
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
|
||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||
|
||||
interface Props {
|
||||
commitMessage: string;
|
||||
@@ -10,11 +10,13 @@
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
stagedCount: number;
|
||||
commitAiProvider: CommitAiProvider;
|
||||
commitAiPhase: CommitAiPhase;
|
||||
commitAiGenerating: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (msg: string) => void;
|
||||
onGenerateCommitMessage: () => void;
|
||||
onOpenAiSettings: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,11 +27,13 @@
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
stagedCount = 0,
|
||||
commitAiProvider = "local",
|
||||
commitAiPhase = "idle",
|
||||
commitAiGenerating = false,
|
||||
onCommit = () => {},
|
||||
onCommitMessageChange = () => {},
|
||||
onGenerateCommitMessage = () => {},
|
||||
onOpenAiSettings = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
@@ -37,15 +41,20 @@
|
||||
onCommit();
|
||||
}
|
||||
|
||||
function aiButtonTitle(phase: CommitAiPhase, staged: number): string {
|
||||
if (phase === "loading") return "AI model is downloading/loading — this happens once";
|
||||
if (phase === "error") return "AI model failed to load";
|
||||
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
|
||||
if (staged === 0) return "Stage changes first";
|
||||
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
|
||||
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
|
||||
return "Generate commit message with AI from the staged diff";
|
||||
}
|
||||
|
||||
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
|
||||
let canGenerate = $derived(
|
||||
hasRepository && !isBusy && !commitAiGenerating && commitAiPhase === "ready" && stagedCount > 0,
|
||||
hasRepository &&
|
||||
!isBusy &&
|
||||
!commitAiGenerating &&
|
||||
stagedCount > 0 &&
|
||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -82,15 +91,25 @@
|
||||
type="button"
|
||||
onclick={onGenerateCommitMessage}
|
||||
disabled={!canGenerate}
|
||||
title={aiButtonTitle(commitAiPhase, stagedCount)}
|
||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||
>
|
||||
{#if commitAiGenerating || commitAiPhase === "loading"}
|
||||
{#if commitAiGenerating || localModelLoading}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
AI
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary commit-ai-settings-button"
|
||||
type="button"
|
||||
onclick={onOpenAiSettings}
|
||||
disabled={isBusy}
|
||||
title="AI settings"
|
||||
aria-label="AI settings"
|
||||
>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
+27
-2
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
CommitAiProvider,
|
||||
CommitAiStatus,
|
||||
ConflictFile,
|
||||
GitBranch,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
GitRepositoryFile,
|
||||
GitSearchHit,
|
||||
GitStatus,
|
||||
LocalModelOption,
|
||||
PatchApplyAction,
|
||||
RepositoryBundle,
|
||||
StoredCredential,
|
||||
@@ -99,8 +101,31 @@ export function commitAiStatus(): Promise<CommitAiStatus> {
|
||||
return invoke<CommitAiStatus>("commit_ai_status");
|
||||
}
|
||||
|
||||
export function commitAiGenerate(path: string, notes?: string): Promise<string> {
|
||||
return invoke<string>("commit_ai_generate", { path, notes });
|
||||
export function commitAiLoad(modelId: string): Promise<void> {
|
||||
return invoke<void>("commit_ai_load", { modelId });
|
||||
}
|
||||
|
||||
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
|
||||
return invoke<LocalModelOption[]>("commit_ai_local_models");
|
||||
}
|
||||
|
||||
export interface CommitAiGenerateOptions {
|
||||
notes?: string;
|
||||
provider: CommitAiProvider;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export function commitAiGenerate(path: string, options: CommitAiGenerateOptions): Promise<string> {
|
||||
return invoke<string>("commit_ai_generate", {
|
||||
path,
|
||||
notes: options.notes,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
apiKey: options.apiKey,
|
||||
baseUrl: options.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||
|
||||
@@ -8,12 +8,29 @@ export type FileStatusKind =
|
||||
| "unknown";
|
||||
|
||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||
|
||||
export interface CommitAiStatus {
|
||||
phase: CommitAiPhase;
|
||||
model_id: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LocalModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
approx_size_mb: number;
|
||||
}
|
||||
|
||||
export interface AiSettings {
|
||||
provider: CommitAiProvider;
|
||||
localModelId: string;
|
||||
openaiModel: string;
|
||||
anthropicModel: string;
|
||||
customBaseUrl: string;
|
||||
customModel: string;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
|
||||
Reference in New Issue
Block a user