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:
+116
-8
@@ -5,6 +5,7 @@
|
||||
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
||||
@@ -26,6 +27,8 @@
|
||||
checkoutBranch,
|
||||
commit,
|
||||
commitAiGenerate,
|
||||
commitAiLoad,
|
||||
commitAiLocalModels,
|
||||
commitAiStatus,
|
||||
compareCommits,
|
||||
cancelCodeSearch,
|
||||
@@ -64,6 +67,7 @@
|
||||
} from "./lib/git";
|
||||
|
||||
import type {
|
||||
AiSettings,
|
||||
CommitAiPhase,
|
||||
ConflictFile,
|
||||
ExplorerNode,
|
||||
@@ -77,6 +81,7 @@
|
||||
GitRepositoryFile,
|
||||
GitSearchHit,
|
||||
GitStatus,
|
||||
LocalModelOption,
|
||||
PatchApplyAction,
|
||||
PreparedResolution,
|
||||
StoredCredential,
|
||||
@@ -104,6 +109,7 @@
|
||||
|
||||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -130,6 +136,9 @@
|
||||
let commitAiPhase: CommitAiPhase = "idle";
|
||||
let commitAiGenerating = false;
|
||||
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let aiSettings: AiSettings = defaultAiSettings();
|
||||
let aiSettingsOpen = false;
|
||||
let localModelOptions: LocalModelOption[] = [];
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
let compareFrom = "";
|
||||
@@ -213,7 +222,7 @@
|
||||
loadRepoLists();
|
||||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||||
void checkForUpdates();
|
||||
startCommitAiPolling();
|
||||
void initCommitAi();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -256,29 +265,85 @@
|
||||
|
||||
// ── Commit AI ──────────────────────────────────────────────────────────────
|
||||
|
||||
function stopCommitAiPolling() {
|
||||
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
|
||||
}
|
||||
|
||||
async function pollCommitAiStatus() {
|
||||
try {
|
||||
const result = await commitAiStatus();
|
||||
commitAiPhase = result.phase;
|
||||
} catch { /* ignore transient errors */ }
|
||||
if (commitAiPhase === "ready" || commitAiPhase === "error") {
|
||||
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
|
||||
}
|
||||
if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling();
|
||||
}
|
||||
|
||||
function startCommitAiPolling() {
|
||||
// The model downloads (first run only) and loads in the background on app start;
|
||||
// poll until it's ready (or failed) so the "AI" button can enable itself.
|
||||
// Only the local model has a download/load phase worth polling — cloud providers are
|
||||
// plain API calls with nothing to wait for.
|
||||
stopCommitAiPolling();
|
||||
if (aiSettings.provider !== "local") return;
|
||||
void pollCommitAiStatus();
|
||||
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
|
||||
}
|
||||
|
||||
async function initCommitAi() {
|
||||
aiSettings = loadAiSettings();
|
||||
try {
|
||||
localModelOptions = await commitAiLocalModels();
|
||||
} catch { /* AI features stay disabled if this fails; not fatal to the app */ }
|
||||
if (aiSettings.provider === "local") {
|
||||
try { await commitAiLoad(aiSettings.localModelId); } catch { /* surfaced via status polling */ }
|
||||
}
|
||||
startCommitAiPolling();
|
||||
}
|
||||
|
||||
function saveAiSettings(next: AiSettings) {
|
||||
const modelChanged = next.provider === "local" && next.localModelId !== aiSettings.localModelId;
|
||||
aiSettings = next;
|
||||
persistAiSettings(next);
|
||||
aiSettingsOpen = false;
|
||||
if (next.provider === "local" && (modelChanged || commitAiPhase === "idle")) {
|
||||
commitAiPhase = "idle";
|
||||
void commitAiLoad(next.localModelId);
|
||||
}
|
||||
startCommitAiPolling();
|
||||
}
|
||||
|
||||
async function generateCommitMessageWithAi() {
|
||||
if (!activeRepoPath || commitAiPhase !== "ready" || commitAiGenerating) return;
|
||||
if (!activeRepoPath || commitAiGenerating) return;
|
||||
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
|
||||
commitAiGenerating = true;
|
||||
errorMessage = "";
|
||||
try {
|
||||
commitMessage = await commitAiGenerate(activeRepoPath, commitMessage.trim() || undefined);
|
||||
const notes = commitMessage.trim() || undefined;
|
||||
if (aiSettings.provider === "local") {
|
||||
commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes });
|
||||
} else if (aiSettings.provider === "openai") {
|
||||
const cred = await credLoad("ai:openai");
|
||||
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||||
provider: "openai",
|
||||
notes,
|
||||
model: aiSettings.openaiModel,
|
||||
apiKey: cred?.password,
|
||||
});
|
||||
} else if (aiSettings.provider === "anthropic") {
|
||||
const cred = await credLoad("ai:anthropic");
|
||||
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||||
provider: "anthropic",
|
||||
notes,
|
||||
model: aiSettings.anthropicModel,
|
||||
apiKey: cred?.password,
|
||||
});
|
||||
} else {
|
||||
const cred = await credLoad("ai:custom");
|
||||
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||||
provider: "custom",
|
||||
notes,
|
||||
model: aiSettings.customModel,
|
||||
baseUrl: aiSettings.customBaseUrl,
|
||||
apiKey: cred?.password,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = errorToMessage(error);
|
||||
} finally {
|
||||
@@ -446,6 +511,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
function defaultAiSettings(): AiSettings {
|
||||
return {
|
||||
provider: "local",
|
||||
localModelId: "qwen2.5-1.5b",
|
||||
openaiModel: "gpt-4o-mini",
|
||||
anthropicModel: "claude-3-5-haiku-latest",
|
||||
customBaseUrl: "",
|
||||
customModel: "",
|
||||
};
|
||||
}
|
||||
|
||||
function loadAiSettings(): AiSettings {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
|
||||
if (stored && typeof stored === "object") {
|
||||
return { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to defaults below.
|
||||
}
|
||||
return defaultAiSettings();
|
||||
}
|
||||
|
||||
function persistAiSettings(next: AiSettings) {
|
||||
try {
|
||||
localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Local storage is best-effort only; AI generation must keep working without it.
|
||||
}
|
||||
}
|
||||
|
||||
function rememberRecentRepo(path: string) {
|
||||
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||||
persistRepoLists();
|
||||
@@ -1742,11 +1838,13 @@
|
||||
{isBusy}
|
||||
{operation}
|
||||
{stagedCount}
|
||||
commitAiProvider={aiSettings.provider}
|
||||
{commitAiPhase}
|
||||
{commitAiGenerating}
|
||||
onCommit={commitChanges}
|
||||
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
||||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1850,6 +1948,16 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Choose the AI provider/model used to generate commit messages -->
|
||||
{#if aiSettingsOpen}
|
||||
<AiSettingsDialog
|
||||
settings={aiSettings}
|
||||
localModels={localModelOptions}
|
||||
onSave={saveAiSettings}
|
||||
onClose={() => { aiSettingsOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Compare: pick the two commits to diff -->
|
||||
{#if compareSelectOpen}
|
||||
<CompareSelectDialog
|
||||
|
||||
Reference in New Issue
Block a user