diff --git a/bun.lock b/bun.lock index 1d077eb4d..96fa01cab 100644 --- a/bun.lock +++ b/bun.lock @@ -750,7 +750,7 @@ "@tree-sitter-grammars/tree-sitter-markdown": ["@tree-sitter-grammars/tree-sitter-markdown@0.3.2", "", { "dependencies": { "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "sha512-hQXCcDVvg2t4E8cn7zz6jjIBerzk9E9ZlHxJp5IrUOpY4s1YVpXJbMeWZks2/V7lmkPRnnkM8IrTbQ5ltwEOnA=="], - "@tree-sitter-grammars/tree-sitter-vue": ["tree-sitter-vue@github:tree-sitter-grammars/tree-sitter-vue#ce8011a", { "dependencies": { "nan": "^2.18.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4", "tree-sitter-html": "=0.23.2" } }, "tree-sitter-grammars-tree-sitter-vue-ce8011a"], + "@tree-sitter-grammars/tree-sitter-vue": ["tree-sitter-vue@github:tree-sitter-grammars/tree-sitter-vue#ce8011a", { "dependencies": { "nan": "^2.18.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4", "tree-sitter-html": "=0.23.2" } }, "tree-sitter-grammars-tree-sitter-vue-ce8011a", "sha512-AmirHA7JluyVR8JF4Y15nOUvZlqxrt6r7fZVCEryv5aZzZ9D+AN3GTK1ga3abcPLZi9uHvTid9QWFPKYVcFMeg=="], "@tree-sitter-grammars/tree-sitter-yaml": ["@tree-sitter-grammars/tree-sitter-yaml@0.7.1", "", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q=="], @@ -1564,7 +1564,7 @@ "tree-sitter-dart": ["tree-sitter-dart@1.0.0", "", { "dependencies": { "nan": "^2.15.0" } }, "sha512-Ve5YMPJjjGW9LEsO+MngAOibQsw5obFp+bUT41pvwdcXWRwJImOWs3eaPi6AubEiBmc09qvhdvxeIXvxlhMnug=="], - "tree-sitter-diff": ["tree-sitter-diff@github:the-mikedavis/tree-sitter-diff#2520c3f", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "tree-sitter-grammars-tree-sitter-diff-2520c3f"], + "tree-sitter-diff": ["tree-sitter-diff@github:the-mikedavis/tree-sitter-diff#2520c3f", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "tree-sitter-grammars-tree-sitter-diff-2520c3f", "sha512-UU1i69jdqh9PUJYiEkLo7h9Xjs+XWoYnN8P/n2OsKQgKVbOh+HQCyvRFYeCNIrxO7C5xu6btXLbsBy9W2Cue4w=="], "tree-sitter-elisp": ["tree-sitter-elisp@1.6.1", "", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" } }, "sha512-ALJ50YOuqu2Z/qKyQMh3RT5OhQmTRc2K/4MZzPdd1eKyFdKPaYpG0ZmPyovNTXyW14Qn63SgY960el55wLwy8Q=="], @@ -1592,7 +1592,7 @@ "tree-sitter-python": ["tree-sitter-python@0.23.6", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-yIM9z0oxKIxT7bAtPOhgoVl6gTXlmlIhue7liFT4oBPF/lha7Ha4dQBS82Av6hMMRZoVnFJI8M6mL+SwWoLD3A=="], - "tree-sitter-rescript": ["tree-sitter-rescript@github:rescript-lang/tree-sitter-rescript#43c2f1f", { "dependencies": { "nan": "^2.15.0", "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "rescript-lang-tree-sitter-rescript-43c2f1f"], + "tree-sitter-rescript": ["tree-sitter-rescript@github:rescript-lang/tree-sitter-rescript#43c2f1f", { "dependencies": { "nan": "^2.15.0", "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "rescript-lang-tree-sitter-rescript-43c2f1f", "sha512-+Rd78bjD5vIfArmX5ppDPH5hcfxbcUpad/g6+xrhfVpERx8KgvJbBSitFmFEci76Oc3MgJMYTadE0SJgnXD+yg=="], "tree-sitter-ruby": ["tree-sitter-ruby@0.23.1", "", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA=="], diff --git a/src/features/editor/components/editor.tsx b/src/features/editor/components/editor.tsx index 1adf86d90..59f1f088f 100644 --- a/src/features/editor/components/editor.tsx +++ b/src/features/editor/components/editor.tsx @@ -153,7 +153,14 @@ export function Editor({ const vimModeEnabled = useSettingsStore((state) => state.settings.vimMode); const setIsFindVisible = useUIState((state) => state.setIsFindVisible); const aiCompletionEnabled = useSettingsStore((state) => state.settings.aiCompletion); + const aiAutocompleteProvider = useSettingsStore((state) => state.settings.aiAutocompleteProvider); const aiAutocompleteModelId = useSettingsStore((state) => state.settings.aiAutocompleteModelId); + const aiAutocompleteCustomBaseUrl = useSettingsStore( + (state) => state.settings.aiAutocompleteCustomBaseUrl, + ); + const aiAutocompleteCustomModelId = useSettingsStore( + (state) => state.settings.aiAutocompleteCustomModelId, + ); const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame); const gitGutterEnabled = useSettingsStore((state) => state.settings.enableGitGutter); const vimMode = useVimStore.use.mode(); @@ -723,7 +730,10 @@ export function Editor({ useAutocomplete({ enabled: aiCompletionEnabled && !isPreviewMode && !readOnly, - model: aiAutocompleteModelId, + provider: aiAutocompleteProvider, + model: + aiAutocompleteProvider === "custom" ? aiAutocompleteCustomModelId : aiAutocompleteModelId, + customBaseUrl: aiAutocompleteCustomBaseUrl, filePath: filePath || null, languageId: filePath ? getLanguageId(filePath) : null, content, @@ -1083,12 +1093,26 @@ export function Editor({ const lineText = lines[visualCursorLine] || ""; const cursorColumn = Math.min(cursorPosition.column, lineText.length); + const textAfterCursorOnLine = lineText.slice(cursorColumn); + if (textAfterCursorOnLine.trim().length > 0) return null; + const cursorX = getAccurateCursorX(lineText, cursorColumn, fontSize, fontFamily, tabSize); + const previewLines: Array<{ text: string; index: number }> = []; + + for (const [index, text] of normalized.split("\n").entries()) { + if (index > 0 && lines[visualCursorLine + index]?.trim()) { + break; + } + previewLines.push({ text, index }); + } + + if (previewLines.every((line) => line.text.length === 0)) return null; return { - text: normalized, + lines: previewLines, top: visualCursorLine * lineHeight + EDITOR_CONSTANTS.EDITOR_PADDING_TOP, - left: cursorX + EDITOR_CONSTANTS.EDITOR_PADDING_LEFT, + firstLineLeft: cursorX + EDITOR_CONSTANTS.EDITOR_PADDING_LEFT, + continuationLeft: EDITOR_CONSTANTS.EDITOR_PADDING_LEFT, }; }, [ autocompleteCompletion, @@ -1330,7 +1354,7 @@ export function Editor({ style={{ position: "absolute", top: `${inlineAutocompletePreview.top}px`, - left: `${inlineAutocompletePreview.left}px`, + left: 0, fontSize: `${fontSize}px`, fontFamily, lineHeight: `${lineHeight}px`, @@ -1339,7 +1363,24 @@ export function Editor({ color: "var(--text-lighter, #94a3b8)", }} > - {inlineAutocompletePreview.text} + {inlineAutocompletePreview.lines.map((line) => { + if (line.text.length === 0) return null; + return ( +
+ {line.text} +
+ ); + })} )} diff --git a/src/features/editor/hooks/use-autocomplete.ts b/src/features/editor/hooks/use-autocomplete.ts index cdbbc61bb..d7885326f 100644 --- a/src/features/editor/hooks/use-autocomplete.ts +++ b/src/features/editor/hooks/use-autocomplete.ts @@ -7,7 +7,9 @@ import { interface UseAutocompleteOptions { enabled: boolean; + provider: "openrouter" | "custom"; model: string; + customBaseUrl: string; filePath: string | null; languageId: string | null; content: string; @@ -24,16 +26,6 @@ const COMPLETION_OVERLAP_SCAN_LIMIT = 256; const WORD_LIKE_TRIGGER_REGEX = /[\w\]})>"'`.]/; const CONTEXT_FOLLOWUP_TRIGGER_REGEX = /[\w\]})>"'`.{;=:[\],(]/; -const DEBUG_AUTOCOMPLETE = false; - -function debugLog(message: string, payload?: Record) { - if (!DEBUG_AUTOCOMPLETE) return; - if (payload) { - console.log(`[Autocomplete] ${message}`, payload); - return; - } - console.log(`[Autocomplete] ${message}`); -} function isWhitespace(char: string): boolean { return char === " " || char === "\t" || char === "\n" || char === "\r"; @@ -107,7 +99,9 @@ function shouldTriggerForCharacter(content: string, cursorOffset: number): boole export function useAutocomplete({ enabled, + provider, model, + customBaseUrl, filePath, languageId, content, @@ -129,6 +123,7 @@ export function useAutocomplete({ const managedPolicy = enterprisePolicy?.managedMode ? enterprisePolicy : null; const isPro = subscriptionStatus === "pro"; const useByok = managedPolicy ? managedPolicy.allowByok && !isPro : !isPro; + const needsAthasAuth = provider !== "custom"; useEffect(() => { return () => { @@ -152,24 +147,14 @@ export function useAutocomplete({ if ( !enabled || - !isAuthenticated || + (needsAthasAuth && !isAuthenticated) || (managedPolicy ? !managedPolicy.aiCompletionEnabled : false) || + !model.trim() || + (provider === "custom" && !customBaseUrl.trim()) || hasActiveFolds || lastInputTimestamp === 0 || cursorOffset <= 0 ) { - if (didUserType) { - debugLog("skip-prereq", { - enabled, - isAuthenticated, - subscriptionStatus, - enterpriseManaged: Boolean(managedPolicy), - aiCompletionEnabled: managedPolicy ? managedPolicy.aiCompletionEnabled : true, - hasActiveFolds, - lastInputTimestamp, - cursorOffset, - }); - } setAutocompleteCompletion(null); return; } @@ -181,12 +166,6 @@ export function useAutocomplete({ } if (!shouldTriggerForCharacter(content, cursorOffset)) { - const previousSignificantChar = getPreviousNonWhitespaceChar(content, cursorOffset - 2); - debugLog("skip-trigger-char", { - charBeforeCursor: content[cursorOffset - 1] || "", - previousChar: content[cursorOffset - 2] || "", - previousSignificantChar, - }); setAutocompleteCompletion(null); return; } @@ -209,13 +188,6 @@ export function useAutocomplete({ abortControllerRef.current = abortController; try { - debugLog("request", { - model, - filePath: filePath || "untitled", - languageId: languageId || "unknown", - cursorOffset, - }); - const result = await requestAutocomplete( { model, @@ -224,7 +196,25 @@ export function useAutocomplete({ filePath: filePath || undefined, languageId: languageId || undefined, }, - { useByok }, + { + useByok: provider === "custom" ? false : useByok, + provider, + customBaseUrl, + onChunk: (partialCompletion) => { + if (abortController.signal.aborted || requestIdRef.current !== requestId) { + return; + } + + const normalizedText = normalizeCompletionText( + partialCompletion, + beforeCursor, + afterCursor, + ); + if (!normalizedText) return; + + setAutocompleteCompletion({ text: normalizedText, cursorOffset }); + }, + }, ); if (abortController.signal.aborted || requestIdRef.current !== requestId) { @@ -233,22 +223,16 @@ export function useAutocomplete({ const text = result.completion; if (!text) { - debugLog("empty-completion"); setAutocompleteCompletion(null); return; } const normalizedText = normalizeCompletionText(text, beforeCursor, afterCursor); if (!normalizedText) { - debugLog("empty-normalized-completion"); setAutocompleteCompletion(null); return; } - debugLog("suggestion-ready", { - rawLength: text.length, - normalizedLength: normalizedText.length, - }); setAutocompleteCompletion({ text: normalizedText, cursorOffset }); } catch (error) { if (abortController.signal.aborted || requestIdRef.current !== requestId) { @@ -276,16 +260,18 @@ export function useAutocomplete({ }; }, [ enabled, + provider, + needsAthasAuth, isAuthenticated, managedPolicy, useByok, - subscriptionStatus, filePath, hasActiveFolds, lastInputTimestamp, cursorOffset, content, model, + customBaseUrl, languageId, setAutocompleteCompletion, ]); diff --git a/src/features/editor/services/editor-autocomplete-service.ts b/src/features/editor/services/editor-autocomplete-service.ts index 372a38b0f..65536b322 100644 --- a/src/features/editor/services/editor-autocomplete-service.ts +++ b/src/features/editor/services/editor-autocomplete-service.ts @@ -5,6 +5,7 @@ import { getApiBase } from "@/utils/api-base"; const API_BASE = getApiBase(); const OPENROUTER_PROVIDER_ID = "openrouter"; +const CUSTOM_AUTOCOMPLETE_PROVIDER_ID = "autocomplete-custom"; const BYOK_HEADER = "X-OpenRouter-Api-Key"; export interface AutocompleteRequest { @@ -15,6 +16,13 @@ export interface AutocompleteRequest { languageId?: string; } +interface AutocompleteOptions { + useByok?: boolean; + provider?: "openrouter" | "custom"; + customBaseUrl?: string; + onChunk?: (completion: string) => void; +} + export interface AutocompleteModel { id: string; name: string; @@ -68,8 +76,12 @@ function parseModelListFromUnknown(payload: unknown): AutocompleteModel[] { export async function requestAutocomplete( request: AutocompleteRequest, - options?: { useByok?: boolean }, + options?: AutocompleteOptions, ): Promise<{ completion: string }> { + if (options?.provider === "custom") { + return requestCustomAutocomplete(request, options.customBaseUrl, options.onChunk); + } + const token = await getAuthToken(); if (!token) { throw new AutocompleteError("Not authenticated", 401); @@ -110,6 +122,284 @@ export async function requestAutocomplete( }; } +function normalizeCustomBaseUrl(baseUrl: string | undefined): string { + const trimmed = baseUrl?.trim().replace(/\/+$/, "") || ""; + if (!trimmed) { + throw new AutocompleteError("Custom autocomplete base URL is required.", 400); + } + + return trimmed.endsWith("/chat/completions") ? trimmed : `${trimmed}/chat/completions`; +} + +function extractCustomCompletion(payload: unknown): string { + if (!payload || typeof payload !== "object") return ""; + + const nestedData = (payload as { data?: unknown }).data; + if (nestedData && typeof nestedData === "object") { + const nestedCompletion = extractCustomCompletion(nestedData); + if (nestedCompletion) return nestedCompletion; + } + + const directCandidate = payload as { + completion?: unknown; + text?: unknown; + insertText?: unknown; + suggestion?: unknown; + }; + for (const value of [ + directCandidate.completion, + directCandidate.text, + directCandidate.insertText, + directCandidate.suggestion, + ]) { + if (typeof value === "string") return value; + } + + const choices = (payload as { choices?: unknown }).choices; + if (!Array.isArray(choices)) return ""; + const firstChoice = choices[0]; + if (!firstChoice || typeof firstChoice !== "object") return ""; + const message = (firstChoice as { message?: unknown }).message; + if (message && typeof message === "object") { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content; + } + const text = (firstChoice as { text?: unknown }).text; + return typeof text === "string" ? text : ""; +} + +function extractCustomStreamChunk(payload: unknown): string { + if (!payload || typeof payload !== "object") return ""; + + const nestedData = (payload as { data?: unknown }).data; + if (nestedData && typeof nestedData === "object") { + const nestedChunk = extractCustomStreamChunk(nestedData); + if (nestedChunk) return nestedChunk; + } + + const choices = (payload as { choices?: unknown }).choices; + if (Array.isArray(choices)) { + const firstChoice = choices[0]; + if (firstChoice && typeof firstChoice === "object") { + const delta = (firstChoice as { delta?: unknown }).delta; + if (delta && typeof delta === "object") { + const content = (delta as { content?: unknown }).content; + if (typeof content === "string") return content; + } + + const text = (firstChoice as { text?: unknown }).text; + if (typeof text === "string") return text; + } + } + + return extractCustomCompletion(payload); +} + +function cleanCustomCompletion(completion: string): string { + let cleaned = completion.replace(/\r\n/g, "\n").trimEnd(); + + const fencedMatch = cleaned.match(/^```(?:[\w-]+)?\n([\s\S]*?)\n```$/); + if (fencedMatch?.[1]) { + cleaned = fencedMatch[1].trimEnd(); + } + + return cleaned; +} + +type CustomAutocompleteRequestBody = { + model: string; + stream: boolean; + temperature: number; + max_tokens: number; + messages: Array<{ + role: "system" | "user"; + content: string; + }>; +}; + +function buildCustomAutocompleteRequestBody( + request: AutocompleteRequest, + stream: boolean, +): CustomAutocompleteRequestBody { + return { + model: request.model, + stream, + temperature: 0.2, + max_tokens: 96, + messages: [ + { + role: "system", + content: [ + "You are an inline code autocomplete engine.", + "Return only the exact text to insert at the cursor.", + "Do not return markdown fences, explanations, or the whole file.", + "Do not repeat text that already appears before or after the cursor.", + "Do not close or recreate outer syntax unless that is the immediate next insertion.", + "Prefer a short continuation: one token, one attribute, one expression, or a few lines at most.", + ].join(" "), + }, + { + role: "user", + content: [ + request.filePath ? `File: ${request.filePath}` : "File: untitled", + request.languageId ? `Language: ${request.languageId}` : "Language: unknown", + "", + request.beforeCursor, + "", + "", + request.afterCursor, + "", + ].join("\n"), + }, + ], + }; +} + +async function requestCustomAutocomplete( + request: AutocompleteRequest, + customBaseUrl: string | undefined, + onChunk?: (completion: string) => void, +): Promise<{ completion: string }> { + if (!request.model.trim()) { + throw new AutocompleteError("Custom autocomplete model is required.", 400); + } + + const apiKey = await getProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID); + const url = normalizeCustomBaseUrl(customBaseUrl); + const requestBody = buildCustomAutocompleteRequestBody(request, true); + + const response = await tauriFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream, application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify(requestBody), + }); + + const contentType = response.headers.get("content-type") || ""; + if (response.ok && response.body && contentType.includes("text/event-stream")) { + const completion = await readCustomStreamingCompletion(response, onChunk); + return { completion: cleanCustomCompletion(completion) }; + } + + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = null; + } + + if (!response.ok) { + const error = body && typeof body === "object" ? (body as { error?: unknown }).error : null; + const message = + error && + typeof error === "object" && + typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : `Custom autocomplete request failed (${response.status})`; + if (requestBody.stream) { + return requestCustomAutocompleteNonStreaming( + request, + url, + apiKey, + buildCustomAutocompleteRequestBody(request, false), + ); + } + + throw new AutocompleteError(message, response.status); + } + + const completion = cleanCustomCompletion(extractCustomCompletion(body)); + return { completion }; +} + +async function readCustomStreamingCompletion( + response: Response, + onChunk?: (completion: string) => void, +): Promise { + const reader = response.body?.getReader(); + if (!reader) { + return ""; + } + + const decoder = new TextDecoder(); + let buffer = ""; + let completion = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine.startsWith("event:")) continue; + if (trimmedLine === "data: [DONE]") return completion; + if (!trimmedLine.startsWith("data:")) continue; + + const json = trimmedLine.slice("data:".length).trim(); + if (!json) continue; + + try { + const chunk = extractCustomStreamChunk(JSON.parse(json)); + if (!chunk) continue; + completion += chunk; + onChunk?.(cleanCustomCompletion(completion)); + } catch (error) { + console.warn("Failed to parse autocomplete stream chunk:", error); + } + } + } + } finally { + reader.releaseLock(); + } + + return completion; +} + +async function requestCustomAutocompleteNonStreaming( + request: AutocompleteRequest, + url: string, + apiKey: string | null, + requestBody: CustomAutocompleteRequestBody, +): Promise<{ completion: string }> { + const response = await tauriFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify(requestBody), + }); + + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = null; + } + + if (!response.ok) { + const error = body && typeof body === "object" ? (body as { error?: unknown }).error : null; + const message = + error && + typeof error === "object" && + typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : `Custom autocomplete request failed (${response.status})`; + throw new AutocompleteError(message, response.status); + } + + const completion = cleanCustomCompletion(extractCustomCompletion(body)); + return { completion }; +} + export async function fetchAutocompleteModels(): Promise { const response = await tauriFetch(`${API_BASE}/api/ai/autocomplete/models`, { method: "GET", diff --git a/src/features/settings/components/tabs/ai-settings.tsx b/src/features/settings/components/tabs/ai-settings.tsx index 63e7c89e8..6c8aa4540 100644 --- a/src/features/settings/components/tabs/ai-settings.tsx +++ b/src/features/settings/components/tabs/ai-settings.tsx @@ -47,6 +47,7 @@ import { storeProviderApiToken, } from "@/features/ai/services/ai-token-service"; const DEFAULT_AUTOCOMPLETE_MODEL_ID = "mistralai/devstral-small"; +const CUSTOM_AUTOCOMPLETE_PROVIDER_ID = "autocomplete-custom"; const DEFAULT_AUTOCOMPLETE_MODELS = [ { id: "mistralai/devstral-small", name: "Devstral Small 1.1" }, { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" }, @@ -75,6 +76,15 @@ export const AISettings = () => { const [autocompleteModels, setAutocompleteModels] = useState(DEFAULT_AUTOCOMPLETE_MODELS); const [isLoadingAutocompleteModels, setIsLoadingAutocompleteModels] = useState(false); const [autocompleteModelError, setAutocompleteModelError] = useState(null); + const [customAutocompleteModelInput, setCustomAutocompleteModelInput] = useState( + settings.aiAutocompleteCustomModelId, + ); + const [customAutocompleteBaseUrlInput, setCustomAutocompleteBaseUrlInput] = useState( + settings.aiAutocompleteCustomBaseUrl, + ); + const [customAutocompleteApiKeyInput, setCustomAutocompleteApiKeyInput] = useState(""); + const [hasCustomAutocompleteApiKey, setHasCustomAutocompleteApiKey] = useState(false); + const [isSavingCustomAutocompleteApiKey, setIsSavingCustomAutocompleteApiKey] = useState(false); const [isApiKeyManagerOpen, setIsApiKeyManagerOpen] = useState(false); // Ollama URL state @@ -229,6 +239,60 @@ export const AISettings = () => { void loadAutocompleteModels(); }, []); + useEffect(() => { + setCustomAutocompleteModelInput(settings.aiAutocompleteCustomModelId); + }, [settings.aiAutocompleteCustomModelId]); + + useEffect(() => { + setCustomAutocompleteBaseUrlInput(settings.aiAutocompleteCustomBaseUrl); + }, [settings.aiAutocompleteCustomBaseUrl]); + + useEffect(() => { + void (async () => { + const token = await getProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID); + setHasCustomAutocompleteApiKey(Boolean(token)); + })(); + }, []); + + const handleSaveCustomAutocompleteApiKey = async () => { + const token = customAutocompleteApiKeyInput.trim(); + if (!token) return; + + setIsSavingCustomAutocompleteApiKey(true); + try { + await storeProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID, token); + setHasCustomAutocompleteApiKey(true); + setCustomAutocompleteApiKeyInput(""); + showToast({ message: "Custom autocomplete API key saved", type: "success" }); + } catch { + showToast({ message: "Failed to save custom autocomplete API key", type: "error" }); + } finally { + setIsSavingCustomAutocompleteApiKey(false); + } + }; + + const handleRemoveCustomAutocompleteApiKey = async () => { + setIsSavingCustomAutocompleteApiKey(true); + try { + await removeProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID); + setHasCustomAutocompleteApiKey(false); + setCustomAutocompleteApiKeyInput(""); + showToast({ message: "Custom autocomplete API key removed", type: "success" }); + } catch { + showToast({ message: "Failed to remove custom autocomplete API key", type: "error" }); + } finally { + setIsSavingCustomAutocompleteApiKey(false); + } + }; + + const commitCustomAutocompleteModel = () => { + updateSetting("aiAutocompleteCustomModelId", customAutocompleteModelInput); + }; + + const commitCustomAutocompleteBaseUrl = () => { + updateSetting("aiAutocompleteCustomBaseUrl", customAutocompleteBaseUrlInput); + }; + const providersNeedingAuth = getAvailableProviders().filter( (p) => p.requiresAuth && !p.requiresApiKey, ); @@ -480,41 +544,180 @@ export const AISettings = () => { {settings.aiCompletion && ( <> - updateSetting("aiAutocompleteModelId", getDefaultSetting("aiAutocompleteModelId")) + updateSetting("aiAutocompleteProvider", getDefaultSetting("aiAutocompleteProvider")) } canReset={ - settings.aiAutocompleteModelId !== getDefaultSetting("aiAutocompleteModelId") + settings.aiAutocompleteProvider !== getDefaultSetting("aiAutocompleteProvider") } > -
- - setCustomAutocompleteModelInput(event.currentTarget.value)} + onBlur={commitCustomAutocompleteModel} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + placeholder="qwen2.5-coder:7b" size="xs" - variant="default" - searchable - searchableTrigger="input" className={SETTINGS_CONTROL_WIDTHS.xwide} disabled={!aiCompletionAllowedByPolicy} /> -
+ ) : ( +
+ + + setCustomAutocompleteBaseUrlInput(event.currentTarget.value) + } + onBlur={commitCustomAutocompleteBaseUrl} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + placeholder="http://localhost:11434/v1" + size="xs" + className={SETTINGS_CONTROL_WIDTHS.xwide} + disabled={!aiCompletionAllowedByPolicy} + /> + + +
+ + setCustomAutocompleteApiKeyInput(event.currentTarget.value) + } + placeholder={hasCustomAutocompleteApiKey ? "Saved" : "API key"} + size="xs" + className={SETTINGS_CONTROL_WIDTHS.wide} + disabled={!aiCompletionAllowedByPolicy || isSavingCustomAutocompleteApiKey} + /> + + {hasCustomAutocompleteApiKey && ( + + )} +
+
+ + )} {autocompleteModelError && ( diff --git a/src/features/settings/config/default-settings.ts b/src/features/settings/config/default-settings.ts index 3c1a2f8e0..523fcae79 100644 --- a/src/features/settings/config/default-settings.ts +++ b/src/features/settings/config/default-settings.ts @@ -16,6 +16,7 @@ import type { Settings } from "@/features/settings/types/settings"; export const DEFAULT_AI_PROVIDER_ID = "anthropic"; export const DEFAULT_AI_MODEL_ID = "claude-sonnet-4-6"; export const DEFAULT_AI_AUTOCOMPLETE_MODEL_ID = "mistralai/devstral-small"; +export const DEFAULT_AI_AUTOCOMPLETE_CUSTOM_BASE_URL = ""; export const defaultSettings: Settings = { // General @@ -65,7 +66,10 @@ export const defaultSettings: Settings = { aiChatWidth: 400, isAIChatVisible: false, aiCompletion: true, + aiAutocompleteProvider: "openrouter", aiAutocompleteModelId: DEFAULT_AI_AUTOCOMPLETE_MODEL_ID, + aiAutocompleteCustomBaseUrl: DEFAULT_AI_AUTOCOMPLETE_CUSTOM_BASE_URL, + aiAutocompleteCustomModelId: "", aiDefaultSessionMode: "", aiSkills: [], ollamaBaseUrl: "http://localhost:11434", diff --git a/src/features/settings/lib/settings-normalization.ts b/src/features/settings/lib/settings-normalization.ts index ad6394704..2b9385c7b 100644 --- a/src/features/settings/lib/settings-normalization.ts +++ b/src/features/settings/lib/settings-normalization.ts @@ -175,6 +175,12 @@ function normalizeAISettings(settings: Settings): Settings { AI_AUTOCOMPLETE_MODEL_MIGRATIONS[normalizedSettings.aiAutocompleteModelId] || normalizedSettings.aiAutocompleteModelId || DEFAULT_AI_AUTOCOMPLETE_MODEL_ID; + normalizedSettings.aiAutocompleteProvider = + normalizedSettings.aiAutocompleteProvider === "custom" ? "custom" : "openrouter"; + normalizedSettings.aiAutocompleteCustomBaseUrl = + normalizedSettings.aiAutocompleteCustomBaseUrl?.trim() || ""; + normalizedSettings.aiAutocompleteCustomModelId = + normalizedSettings.aiAutocompleteCustomModelId?.trim() || ""; normalizedSettings.aiSkills = normalizeAISkills(normalizedSettings.aiSkills); return normalizedSettings; @@ -295,5 +301,17 @@ export function normalizeSettingValue( return normalizeAISkills(value as Settings["aiSkills"]) as Settings[K]; } + if (key === "aiAutocompleteProvider") { + return (value === "custom" ? "custom" : "openrouter") as Settings[K]; + } + + if (key === "aiAutocompleteCustomBaseUrl") { + return (value as string).trim() as Settings[K]; + } + + if (key === "aiAutocompleteCustomModelId") { + return (value as string).trim() as Settings[K]; + } + return value; } diff --git a/src/features/settings/lib/settings-sync.ts b/src/features/settings/lib/settings-sync.ts index e222e5275..e207fc4d1 100644 --- a/src/features/settings/lib/settings-sync.ts +++ b/src/features/settings/lib/settings-sync.ts @@ -54,7 +54,10 @@ type SyncableSettingsKey = | "aiChatWidth" | "isAIChatVisible" | "aiCompletion" + | "aiAutocompleteProvider" | "aiAutocompleteModelId" + | "aiAutocompleteCustomBaseUrl" + | "aiAutocompleteCustomModelId" | "aiDefaultSessionMode" | "aiSkills" | "ollamaBaseUrl" @@ -136,7 +139,10 @@ const SYNCABLE_SETTINGS_KEYS: SyncableSettingsKey[] = [ "aiChatWidth", "isAIChatVisible", "aiCompletion", + "aiAutocompleteProvider", "aiAutocompleteModelId", + "aiAutocompleteCustomBaseUrl", + "aiAutocompleteCustomModelId", "aiDefaultSessionMode", "aiSkills", "ollamaBaseUrl", diff --git a/src/features/settings/types/settings.ts b/src/features/settings/types/settings.ts index fd67e3dbd..56651f7bc 100644 --- a/src/features/settings/types/settings.ts +++ b/src/features/settings/types/settings.ts @@ -57,7 +57,10 @@ export interface Settings { aiChatWidth: number; isAIChatVisible: boolean; aiCompletion: boolean; + aiAutocompleteProvider: "openrouter" | "custom"; aiAutocompleteModelId: string; + aiAutocompleteCustomBaseUrl: string; + aiAutocompleteCustomModelId: string; aiDefaultSessionMode: string; aiSkills: AIChatSkill[]; ollamaBaseUrl: string; diff --git a/vite.config.ts b/vite.config.ts index ac7ec038a..95fb28ec7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,8 +56,9 @@ export default defineConfig({ } : undefined, watch: { - // 3. tell vite to ignore watching `src-tauri` and `interceptor` - ignored: ["**/src-tauri/**", "**/interceptor/**"], + // 3. tell vite to ignore app-owned files that should not reload the + // editor while they are being edited from inside the editor itself. + ignored: ["**/src-tauri/**", "**/interceptor/**", "**/index.html"], }, }, });