From e11a0d467d9f120644cf5d27074b1c51dd468143 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 24 Aug 2026 06:03:49 +0800 Subject: [PATCH] feat(ai): add generic AI configuration deep links --- apps/desktop/src/App.vue | 36 +++++ .../editor/EditorSettingsDialog.vue | 54 ++++++- .../desktop/src/composables/useTauriEvents.ts | 19 ++- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + .../lib/ai/__tests__/aiConfigDeepLink.spec.ts | 142 ++++++++++++++++++ apps/desktop/src/lib/ai/aiConfigDeepLink.ts | 131 ++++++++++++++++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 4 + apps/desktop/src/lib/backend/tauri.ts | 4 + .../__tests__/settingsPageNavigation.spec.ts | 17 +++ src-tauri/src/commands/deep_link.rs | 68 +++++++-- src-tauri/src/lib.rs | 28 +++- 13 files changed, 503 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/lib/ai/__tests__/aiConfigDeepLink.spec.ts create mode 100644 apps/desktop/src/lib/ai/aiConfigDeepLink.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 21618417ac..7082344288 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -65,6 +65,7 @@ import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociat import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; +import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; import { isBrowserReloadShortcut, isCloseOtherTabsShortcut, @@ -223,6 +224,8 @@ const settingsPageTabOpen = ref(false); const settingsInitialTab = ref("appearance"); const settingsInitialSection = ref(undefined); const settingsNavigationRequestId = ref(0); +const settingsAiConfigDraft = ref(null); +const settingsAiConfigRequestId = ref(0); const showQueryEditorDdlDialog = ref(false); const showQueryEditorObjectSourceDialog = ref(false); const driverStoreTabOpen = ref(false); @@ -511,6 +514,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openSqlFilePath, openDbFilePath, openConnectionDeepLink, + openAiConfigDeepLink, }); const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose }); useVisibilityChange(); @@ -1660,6 +1664,35 @@ async function openPendingConnectionLinks() { } } +async function openAiConfigDeepLink(url: string) { + try { + const draft = parseAiConfigDeepLink(url); + if (!draft) return; + settingsAiConfigDraft.value = draft; + settingsAiConfigRequestId.value += 1; + openSettings("ai"); + } catch (e: any) { + toast( + t("ai.deepLinkInvalid", { + message: e?.message || String(e), + }), + 5000, + ); + } +} + +async function openPendingAiConfigLinks() { + if (!isTauriRuntime()) return; + try { + const links = await api.pendingOpenAiConfigLinks(); + for (const link of links) { + await openAiConfigDeepLink(link); + } + } catch { + /* ignore startup deep-link probing errors */ + } +} + function setConnectionDialogOpen(value: boolean) { showConnectionDialog.value = value; if (!value) { @@ -2779,6 +2812,7 @@ onMounted(async () => { void openPendingSqlFiles(); void openPendingDbFiles(); void openPendingConnectionLinks(); + void openPendingAiConfigLinks(); console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`); }); @@ -2890,6 +2924,8 @@ onUnmounted(() => { :initial-tab="settingsInitialTab" :initial-section="settingsInitialSection" :navigation-request-id="settingsNavigationRequestId" + :ai-config-draft="settingsAiConfigDraft" + :ai-config-request-id="settingsAiConfigRequestId" :app-version="appVersion" :checking-updates="checkingUpdates" class="flex-1 min-h-0" diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index ca0251bbe0..109d673ef3 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -65,7 +65,8 @@ import TunnelProfileManager from "@/components/connection/TunnelProfileManager.v import DangerConfirmDialog from "./DangerConfirmDialog.vue"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useTheme } from "@/composables/useTheme"; -import { copyToClipboard } from "@/lib/common/clipboard"; +import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard"; +import { importClipboardApiKeyAfterConfirmation, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; import { clearDebugLogs as clearStoredDebugLogs, downloadDebugLogs, getDebugLogBundleText } from "@/lib/backend/debugLog"; import { aiTestConnection, @@ -205,6 +206,8 @@ const props = defineProps<{ initialTab?: string; initialSection?: string; navigationRequestId?: number; + aiConfigDraft?: AiConfigDeepLinkDraft | null; + aiConfigRequestId?: number; appVersion?: string; checkingUpdates?: boolean; }>(); @@ -2420,6 +2423,7 @@ async function scrollToInitialSettingsSection() { const aiConfigListMode = ref<"list" | "edit">("list"); const aiEditConfigName = ref(""); const aiEditConfigId = ref(null); +let handledAiConfigRequestId = 0; const displayedAiConfigs = computed(() => orderAiConfigsForDisplay(settingsStore.aiConfigs)); watch( @@ -2479,6 +2483,7 @@ watch( await refreshSnippetTokenStatus(); await refreshSnippetSyncSettings(); syncAiEditState(); + await applyPendingAiConfigDeepLinkDraft(); if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus(); if (!isWeb && activeSettingsTab.value === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus(); if (activeSettingsTab.value === "about") void refreshAppSupportInfo(); @@ -2515,6 +2520,13 @@ watch( }, ); +watch( + () => props.aiConfigRequestId, + () => { + if (settingsVisible.value) void applyPendingAiConfigDeepLinkDraft(); + }, +); + watch([webdavEndpoint, webdavUsername], () => { void refreshWebDavPasswordStatus(); }); @@ -3220,6 +3232,46 @@ function aiEnterEditMode(configId?: string) { } } +async function applyPendingAiConfigDeepLinkDraft() { + const requestId = props.aiConfigRequestId ?? 0; + const draft = props.aiConfigDraft; + if (!settingsVisible.value || !requestId || requestId === handledAiConfigRequestId || !draft) return; + + handledAiConfigRequestId = requestId; + activeSettingsTab.value = "ai"; + aiEnterEditMode(); + aiEditConfigName.value = draft.name; + aiEditProvider.value = draft.provider; + aiEditApiKey.value = ""; + aiEditAuthMethod.value = draft.authMethod; + aiEditEndpoint.value = draft.endpoint; + aiEditModel.value = draft.model; + aiEditLegacyModels.value = []; + aiEditApiStyle.value = draft.apiStyle; + + if (!draft.promptForClipboardApiKey) return; + + try { + const result = await importClipboardApiKeyAfterConfirmation(async () => { + const { ask } = await import("@tauri-apps/plugin-dialog"); + return ask(t("ai.deepLinkClipboardPrompt"), { + title: t("ai.deepLinkClipboardTitle"), + kind: "info", + }); + }, readTextFromClipboard); + if (result.kind === "accepted") aiEditApiKey.value = result.apiKey; + else if (result.kind === "empty") toast(t("ai.deepLinkClipboardEmpty"), 4000); + else if (result.kind === "invalid") toast(t("ai.deepLinkClipboardInvalid"), 4000); + } catch (e: any) { + toast( + t("ai.deepLinkClipboardReadFailed", { + message: e?.message || String(e), + }), + 5000, + ); + } +} + async function aiSaveConfig() { const validationResult: ConfigNameValidationResult = validateConfigName(aiEditConfigName.value, settingsStore.aiConfigs, aiEditConfigId.value || undefined); if (validationResult === "empty") { diff --git a/apps/desktop/src/composables/useTauriEvents.ts b/apps/desktop/src/composables/useTauriEvents.ts index 1520bbf8b7..a3325497f6 100644 --- a/apps/desktop/src/composables/useTauriEvents.ts +++ b/apps/desktop/src/composables/useTauriEvents.ts @@ -3,7 +3,13 @@ import { useQueryStore } from "@/stores/queryStore"; import type { NavigationTarget } from "@/composables/useNavigationTargets"; import type { QueryResult } from "@/types/database"; -export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarget) => Promise; openSqlFilePath: (path: string) => Promise; openDbFilePath: (path: string) => Promise; openConnectionDeepLink: (url: string) => Promise }) { +export function useTauriEvents(deps: { + openTableTarget: (target: NavigationTarget) => Promise; + openSqlFilePath: (path: string) => Promise; + openDbFilePath: (path: string) => Promise; + openConnectionDeepLink: (url: string) => Promise; + openAiConfigDeepLink: (url: string) => Promise; +}) { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const unlistenHandles: Array<() => void> = []; @@ -99,6 +105,17 @@ export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarge console.error("[DBX] dbx-open-connection-links error:", e); } }).then((unlisten) => unlistenHandles.push(unlisten)); + + listen("dbx-open-ai-config-links", async (event) => { + try { + for (const url of event.payload) { + await deps.openAiConfigDeepLink(url); + } + focusCurrentWindow(); + } catch (e) { + console.error("[DBX] dbx-open-ai-config-links error:", e); + } + }).then((unlisten) => unlistenHandles.push(unlisten)); }) .catch(() => {}); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index c48a252229..9c6c097735 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2429,6 +2429,12 @@ export default { effortUnsupported: "This model does not support configurable effort", configNameEmpty: "Config name cannot be empty", configNameExists: "Config name '{name}' already exists", + deepLinkInvalid: "Unable to open AI configuration link: {message}", + deepLinkClipboardTitle: "Import API Key", + deepLinkClipboardPrompt: "Read the API key from the clipboard?", + deepLinkClipboardEmpty: "The clipboard does not contain an API key. Enter it manually.", + deepLinkClipboardInvalid: "The clipboard does not contain a valid single-line API key. Enter it manually.", + deepLinkClipboardReadFailed: "Failed to read the clipboard: {message}", configList: "AI Config List", addConfig: "Add Config", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 10d25c14e2..daae58b83e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2353,6 +2353,12 @@ export default withEnglishFallback({ effortUnsupported: "此模型不支持设置推理强度", configNameEmpty: "配置名称不能为空", configNameExists: "配置名称 '{name}' 已存在", + deepLinkInvalid: "无法打开 AI 配置链接:{message}", + deepLinkClipboardTitle: "导入 API Key", + deepLinkClipboardPrompt: "是否从剪贴板读取 API Key?", + deepLinkClipboardEmpty: "剪贴板中没有可用的 API Key,请手动填写。", + deepLinkClipboardInvalid: "剪贴板内容不是有效的单行 API Key,请手动填写。", + deepLinkClipboardReadFailed: "读取剪贴板失败:{message}", configList: "AI 配置列表", addConfig: "新增配置", diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigDeepLink.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigDeepLink.spec.ts new file mode 100644 index 0000000000..e8bd50ba0a --- /dev/null +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigDeepLink.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import { clipboardApiKeyCandidate, importClipboardApiKeyAfterConfirmation, parseAiConfigDeepLink } from "@/lib/ai/aiConfigDeepLink"; + +function buildLink(params: Record): string { + const url = new URL("dbx://settings/ai/new"); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + return url.toString(); +} + +describe("AI configuration deep links", () => { + it("parses a public OpenAI-compatible configuration without a secret", () => { + const draft = parseAiConfigDeepLink( + buildLink({ + v: "1", + name: "Example AI", + provider: "openai-compatible", + endpoint: "https://api.example.com/v1/", + model: "example-model", + auth: "bearer", + api: "responses", + clipboard: "prompt", + }), + ); + + expect(draft).toEqual({ + name: "Example AI", + provider: "openai-compatible", + endpoint: "https://api.example.com/v1", + model: "example-model", + authMethod: "bearer", + apiStyle: "responses", + promptForClipboardApiKey: true, + }); + }); + + it("uses protocol defaults when auth and API style are omitted", () => { + expect( + parseAiConfigDeepLink( + buildLink({ + name: "Example Messages API", + provider: "anthropic-compatible", + endpoint: "https://messages.example.com/v1/messages", + model: "example-model", + }), + ), + ).toMatchObject({ authMethod: "api-key", apiStyle: "anthropic-messages", promptForClipboardApiKey: false }); + + expect( + parseAiConfigDeepLink( + buildLink({ + name: "Example Custom Messages API", + provider: "custom", + endpoint: "https://messages.example.com/v1/messages", + model: "example-model", + api: "anthropic-messages", + }), + ), + ).toMatchObject({ authMethod: "api-key", apiStyle: "anthropic-messages" }); + }); + + it("ignores unrelated DBX routes", () => { + expect(parseAiConfigDeepLink("dbx://open")).toBeNull(); + expect(parseAiConfigDeepLink("dbx://connection/new?type=mysql")).toBeNull(); + }); + + it("rejects secrets and ambiguous parameters in the URL", () => { + expect(() => + parseAiConfigDeepLink( + buildLink({ + name: "Example AI", + provider: "openai-compatible", + endpoint: "https://api.example.com/v1", + model: "example-model", + api_key: "secret", + }), + ), + ).toThrow(/must not be included/); + + const duplicate = `${buildLink({ name: "Example AI", provider: "custom", endpoint: "https://api.example.com/v1", model: "example-model" })}&model=other-model`; + expect(() => parseAiConfigDeepLink(duplicate)).toThrow(/Duplicate parameter/); + + expect(() => parseAiConfigDeepLink("dbx://secret@settings/ai/new?name=Example&provider=custom&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&model=example#token")).toThrow(/must not be included/); + }); + + it("rejects unsafe or incompatible endpoints and API styles", () => { + expect(() => + parseAiConfigDeepLink( + buildLink({ + name: "Example AI", + provider: "openai-compatible", + endpoint: "file:///tmp/api", + model: "example-model", + }), + ), + ).toThrow(/HTTP or HTTPS/); + + expect(() => + parseAiConfigDeepLink( + buildLink({ + name: "Example AI", + provider: "openai-compatible", + endpoint: "https://user:password@api.example.com/v1", + model: "example-model", + }), + ), + ).toThrow(/must not include credentials/); + + expect(() => + parseAiConfigDeepLink( + buildLink({ + name: "Example AI", + provider: "anthropic-compatible", + endpoint: "https://api.example.com/v1/messages", + model: "example-model", + api: "responses", + }), + ), + ).toThrow(/require the messages API/); + }); +}); + +describe("clipboard API key candidates", () => { + it("accepts a trimmed single-line value", () => { + expect(clipboardApiKeyCandidate(" example-secret ")).toBe("example-secret"); + }); + + it("rejects empty, multiline, and oversized values", () => { + expect(clipboardApiKeyCandidate(" ")).toBeNull(); + expect(clipboardApiKeyCandidate("first\nsecond")).toBeNull(); + expect(clipboardApiKeyCandidate("x".repeat(4097))).toBeNull(); + }); + + it("does not read the clipboard until the user confirms", async () => { + const readClipboard = vi.fn(async () => "example-secret"); + + await expect(importClipboardApiKeyAfterConfirmation(async () => false, readClipboard)).resolves.toEqual({ kind: "declined" }); + expect(readClipboard).not.toHaveBeenCalled(); + + await expect(importClipboardApiKeyAfterConfirmation(async () => true, readClipboard)).resolves.toEqual({ kind: "accepted", apiKey: "example-secret" }); + expect(readClipboard).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/lib/ai/aiConfigDeepLink.ts b/apps/desktop/src/lib/ai/aiConfigDeepLink.ts new file mode 100644 index 0000000000..b3d1e124d9 --- /dev/null +++ b/apps/desktop/src/lib/ai/aiConfigDeepLink.ts @@ -0,0 +1,131 @@ +import type { AiApiStyle, AiAuthMethod, AiProvider } from "@/types/ai"; + +const AI_CONFIG_DEEP_LINK_HOST = "settings"; +const AI_CONFIG_DEEP_LINK_PATH = "/ai/new"; +const MAX_DEEP_LINK_LENGTH = 4096; +const MAX_CONFIG_NAME_LENGTH = 120; +const MAX_ENDPOINT_LENGTH = 2048; +const MAX_MODEL_LENGTH = 256; +const MAX_CLIPBOARD_API_KEY_LENGTH = 4096; + +const SUPPORTED_PROVIDERS = new Set(["openai-compatible", "anthropic-compatible", "custom"]); +const SUPPORTED_PARAMS = new Set(["v", "name", "provider", "endpoint", "model", "auth", "api", "clipboard"]); +const SENSITIVE_PARAMS = new Set(["api_key", "apikey", "authorization", "password", "secret", "token"]); + +export interface AiConfigDeepLinkDraft { + name: string; + provider: AiProvider; + endpoint: string; + model: string; + authMethod: AiAuthMethod; + apiStyle: AiApiStyle; + promptForClipboardApiKey: boolean; +} + +export type ClipboardApiKeyImportResult = { kind: "declined" } | { kind: "empty" } | { kind: "invalid" } | { kind: "accepted"; apiKey: string }; + +function requiredParam(url: URL, name: string, maxLength: number): string { + const value = url.searchParams.get(name)?.trim() ?? ""; + if (!value) throw new Error(`Missing ${name}`); + if (value.length > maxLength) throw new Error(`${name} is too long`); + return value; +} + +function parseProvider(value: string): AiProvider { + if (!SUPPORTED_PROVIDERS.has(value as AiProvider)) throw new Error("Unsupported AI provider"); + return value as AiProvider; +} + +function defaultAuthMethod(provider: AiProvider, apiStyle: AiApiStyle): AiAuthMethod { + return provider === "anthropic-compatible" || apiStyle === "anthropic-messages" ? "api-key" : "bearer"; +} + +function defaultApiStyle(provider: AiProvider): AiApiStyle { + return provider === "anthropic-compatible" ? "anthropic-messages" : "completions"; +} + +function parseAuthMethod(value: string | null, provider: AiProvider, apiStyle: AiApiStyle): AiAuthMethod { + if (!value) return defaultAuthMethod(provider, apiStyle); + if (value !== "api-key" && value !== "bearer") throw new Error("Unsupported AI authentication method"); + return value; +} + +function parseApiStyle(value: string | null, provider: AiProvider): AiApiStyle { + const style = value || defaultApiStyle(provider); + if (style !== "completions" && style !== "responses" && style !== "anthropic-messages") throw new Error("Unsupported AI API style"); + if (provider === "anthropic-compatible" && style !== "anthropic-messages") throw new Error("Anthropic-compatible providers require the messages API"); + if (provider === "openai-compatible" && style === "anthropic-messages") throw new Error("OpenAI-compatible providers do not support the Anthropic messages API"); + return style; +} + +function parseEndpoint(value: string): string { + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + throw new Error("Invalid AI endpoint"); + } + if (endpoint.protocol !== "https:" && endpoint.protocol !== "http:") throw new Error("AI endpoint must use HTTP or HTTPS"); + if (!endpoint.hostname) throw new Error("AI endpoint must include a host"); + if (endpoint.username || endpoint.password) throw new Error("AI endpoint must not include credentials"); + if (endpoint.hash) throw new Error("AI endpoint must not include a fragment"); + return value.replace(/\/+$/, ""); +} + +function validateParams(url: URL): void { + const seen = new Set(); + for (const key of url.searchParams.keys()) { + const normalized = key.toLowerCase(); + if (SENSITIVE_PARAMS.has(normalized)) throw new Error("API keys and other secrets must not be included in the link"); + if (!SUPPORTED_PARAMS.has(normalized) || key !== normalized) throw new Error(`Unsupported parameter: ${key}`); + if (seen.has(normalized)) throw new Error(`Duplicate parameter: ${key}`); + seen.add(normalized); + } +} + +export function parseAiConfigDeepLink(value: string): AiConfigDeepLinkDraft | null { + const trimmed = value.trim(); + if (trimmed.length > MAX_DEEP_LINK_LENGTH) throw new Error("AI configuration link is too long"); + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + if (url.protocol !== "dbx:" || url.hostname !== AI_CONFIG_DEEP_LINK_HOST || url.pathname !== AI_CONFIG_DEEP_LINK_PATH) return null; + if (url.username || url.password || url.hash) throw new Error("API keys and other secrets must not be included in the link"); + + validateParams(url); + const version = url.searchParams.get("v"); + if (version && version !== "1") throw new Error("Unsupported AI configuration link version"); + + const provider = parseProvider(requiredParam(url, "provider", 64)); + const apiStyle = parseApiStyle(url.searchParams.get("api"), provider); + const clipboard = url.searchParams.get("clipboard"); + if (clipboard && clipboard !== "prompt") throw new Error("Unsupported clipboard behavior"); + + return { + name: requiredParam(url, "name", MAX_CONFIG_NAME_LENGTH), + provider, + endpoint: parseEndpoint(requiredParam(url, "endpoint", MAX_ENDPOINT_LENGTH)), + model: requiredParam(url, "model", MAX_MODEL_LENGTH), + authMethod: parseAuthMethod(url.searchParams.get("auth"), provider, apiStyle), + apiStyle, + promptForClipboardApiKey: clipboard === "prompt", + }; +} + +export function clipboardApiKeyCandidate(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_CLIPBOARD_API_KEY_LENGTH || /[\r\n]/.test(trimmed)) return null; + return trimmed; +} + +export async function importClipboardApiKeyAfterConfirmation(confirmRead: () => Promise, readClipboard: () => Promise): Promise { + if (!(await confirmRead())) return { kind: "declined" }; + const clipboardText = await readClipboard(); + const apiKey = clipboardApiKeyCandidate(clipboardText); + if (apiKey) return { kind: "accepted", apiKey }; + return clipboardText.trim() ? { kind: "invalid" } : { kind: "empty" }; +} diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 51e05ab8ff..2efa2150e8 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -365,6 +365,7 @@ export const listenSqlFileProgress = forward("listenSqlFileProgress"); export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles"); export const pendingOpenDbFiles = forward("pendingOpenDbFiles"); export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks"); +export const pendingOpenAiConfigLinks = forward("pendingOpenAiConfigLinks"); export const readExternalSqlFile = forward("readExternalSqlFile"); export const readExternalSqlFileSnapshot = forward("readExternalSqlFileSnapshot"); export const inspectExternalSqlFile = forward("inspectExternalSqlFile"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 7794dbe4fc..f05ef8d269 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2139,6 +2139,10 @@ export async function pendingOpenConnectionLinks(): Promise { return []; } +export async function pendingOpenAiConfigLinks(): Promise { + return []; +} + export async function readExternalSqlFile(_path: string): Promise { throw new Error("Opening external SQL file paths is only available in the desktop app"); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index f8011801e1..764bf146e9 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -825,6 +825,10 @@ export async function pendingOpenConnectionLinks(): Promise { return invoke("pending_open_connection_links"); } +export async function pendingOpenAiConfigLinks(): Promise { + return invoke("pending_open_ai_config_links"); +} + export interface ExternalSqlFileSnapshot { content: string; version: ExternalSqlFileVersion; diff --git a/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts index 9ffdd244e0..a1f1505514 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts @@ -11,4 +11,21 @@ describe("settings page navigation", () => { expect(settingsDialogSource).toContain("navigationRequestId?: number;"); expect(settingsDialogSource).toMatch(/watch\(\s*\(\) => props\.navigationRequestId,/); }); + + it("routes AI configuration drafts into the unsaved new-config form", () => { + const handlerStart = settingsDialogSource.indexOf("async function applyPendingAiConfigDeepLinkDraft()"); + const handlerEnd = settingsDialogSource.indexOf("async function aiSaveConfig()", handlerStart); + const handlerSource = settingsDialogSource.slice(handlerStart, handlerEnd); + + expect(appSource).toContain('openSettings("ai")'); + expect(appSource).toContain(':ai-config-draft="settingsAiConfigDraft"'); + expect(appSource).toContain(':ai-config-request-id="settingsAiConfigRequestId"'); + expect(settingsDialogSource).toContain("aiEnterEditMode();"); + expect(settingsDialogSource).toContain('aiEditApiKey.value = "";'); + expect(settingsDialogSource).toContain("importClipboardApiKeyAfterConfirmation"); + expect(handlerStart).toBeGreaterThanOrEqual(0); + expect(handlerEnd).toBeGreaterThan(handlerStart); + expect(handlerSource).not.toContain("aiSaveConfig("); + expect(handlerSource).not.toContain("aiTestConn("); + }); }); diff --git a/src-tauri/src/commands/deep_link.rs b/src-tauri/src/commands/deep_link.rs index 32787c9b2f..c4f388d4e8 100644 --- a/src-tauri/src/commands/deep_link.rs +++ b/src-tauri/src/commands/deep_link.rs @@ -2,30 +2,50 @@ use std::collections::HashSet; use std::sync::Mutex; const CONNECTION_DEEP_LINK_PREFIX: &str = "dbx://connection/new"; +const AI_CONFIG_DEEP_LINK_PREFIX: &str = "dbx://settings/ai/new"; const APP_OPEN_DEEP_LINK_PREFIX: &str = "dbx://open"; #[tauri::command] pub fn pending_open_connection_links(state: tauri::State<'_, DeepLinkOpenState>) -> Vec { - dedupe_links(state.drain()) + dedupe_links(state.drain_connection_links()) +} + +#[tauri::command] +pub fn pending_open_ai_config_links(state: tauri::State<'_, DeepLinkOpenState>) -> Vec { + dedupe_links(state.drain_ai_config_links()) } #[derive(Default)] pub struct DeepLinkOpenState { - pending: Mutex>, + pending_connection_links: Mutex>, + pending_ai_config_links: Mutex>, } impl DeepLinkOpenState { - pub fn push(&self, links: Vec) { + pub fn push_connection_links(&self, links: Vec) { if links.is_empty() { return; } - if let Ok(mut pending) = self.pending.lock() { + if let Ok(mut pending) = self.pending_connection_links.lock() { pending.extend(links); } } - fn drain(&self) -> Vec { - self.pending.lock().map(|mut pending| pending.drain(..).collect()).unwrap_or_default() + pub fn push_ai_config_links(&self, links: Vec) { + if links.is_empty() { + return; + } + if let Ok(mut pending) = self.pending_ai_config_links.lock() { + pending.extend(links); + } + } + + fn drain_connection_links(&self) -> Vec { + self.pending_connection_links.lock().map(|mut pending| pending.drain(..).collect()).unwrap_or_default() + } + + fn drain_ai_config_links(&self) -> Vec { + self.pending_ai_config_links.lock().map(|mut pending| pending.drain(..).collect()).unwrap_or_default() } } @@ -42,6 +62,19 @@ pub fn connection_deep_link_from_arg(arg: &str) -> Option { matches_deep_link_target(trimmed, CONNECTION_DEEP_LINK_PREFIX).then(|| trimmed.to_string()) } +pub fn ai_config_deep_links_from_args(args: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().filter_map(|arg| ai_config_deep_link_from_arg(arg.as_ref())).collect() +} + +pub fn ai_config_deep_link_from_arg(arg: &str) -> Option { + let trimmed = arg.trim(); + matches_deep_link_target(trimmed, AI_CONFIG_DEEP_LINK_PREFIX).then(|| trimmed.to_string()) +} + pub fn is_app_open_deep_link(arg: &str) -> bool { matches_deep_link_target(arg.trim(), APP_OPEN_DEEP_LINK_PREFIX) } @@ -77,6 +110,7 @@ mod tests { fn filters_connection_deep_links() { let links = connection_deep_links_from_args([ "dbx://connection/new?type=mysql&host=127.0.0.1", + "dbx://settings/ai/new?provider=openai-compatible", "--flag", "dbx://open?x=1", "dbx://connections/new?type=postgres", @@ -86,6 +120,19 @@ mod tests { assert_eq!(links, vec!["dbx://connection/new?type=mysql&host=127.0.0.1".to_string()]); } + #[test] + fn filters_ai_config_deep_links() { + let links = ai_config_deep_links_from_args([ + "dbx://settings/ai/new?v=1&provider=openai-compatible", + "--flag", + "dbx://settings/ai/edit?provider=openai-compatible", + "dbx://settings/ai/newer?provider=openai-compatible", + "dbx://connection/new?type=mysql", + ]); + + assert_eq!(links, vec!["dbx://settings/ai/new?v=1&provider=openai-compatible".to_string()]); + } + #[test] fn recognizes_app_open_deep_links() { assert!(is_app_open_deep_link("dbx://open")); @@ -99,10 +146,13 @@ mod tests { #[test] fn drains_pending_links_once() { let state = DeepLinkOpenState::default(); - state.push(vec!["dbx://connection/new?type=mysql".to_string()]); + state.push_connection_links(vec!["dbx://connection/new?type=mysql".to_string()]); + state.push_ai_config_links(vec!["dbx://settings/ai/new?provider=openai-compatible".to_string()]); - assert_eq!(state.drain(), vec!["dbx://connection/new?type=mysql"]); - assert!(state.drain().is_empty()); + assert_eq!(state.drain_connection_links(), vec!["dbx://connection/new?type=mysql"]); + assert_eq!(state.drain_ai_config_links(), vec!["dbx://settings/ai/new?provider=openai-compatible"]); + assert!(state.drain_connection_links().is_empty()); + assert!(state.drain_ai_config_links().is_empty()); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af5ff5165f..c445150b06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -725,12 +725,23 @@ fn open_connection_deep_links(app: &tauri::AppHandle, links: Vec) { return; } if let Some(state) = app.try_state::() { - state.push(links.clone()); + state.push_connection_links(links.clone()); } let _ = app.emit("dbx-open-connection-links", links); show_main_window(app); } +fn open_ai_config_deep_links(app: &tauri::AppHandle, links: Vec) { + if links.is_empty() { + return; + } + if let Some(state) = app.try_state::() { + state.push_ai_config_links(links.clone()); + } + let _ = app.emit("dbx-open-ai-config-links", links); + show_main_window(app); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LocaleFamily { English, @@ -1521,6 +1532,8 @@ pub fn run() { let app_open_requested = args.iter().any(|arg| commands::deep_link::is_app_open_deep_link(arg)); let links = commands::deep_link::connection_deep_links_from_args(args.clone()); open_connection_deep_links(app, links); + let ai_config_links = commands::deep_link::ai_config_deep_links_from_args(args.clone()); + open_ai_config_deep_links(app, ai_config_links); let paths = commands::external_sql::sql_file_paths_from_args(args.clone(), std::path::Path::new(&cwd)); if !paths.is_empty() { @@ -1720,8 +1733,11 @@ pub fn run() { macos_escape_guard::install_escape_fullscreen_guard(); #[cfg(target_os = "windows")] webview2_recovery::install(app.handle()); - let startup_links = commands::deep_link::connection_deep_links_from_args(std::env::args().skip(1)); + let startup_args: Vec = std::env::args().skip(1).collect(); + let startup_links = commands::deep_link::connection_deep_links_from_args(&startup_args); open_connection_deep_links(app.handle(), startup_links); + let startup_ai_config_links = commands::deep_link::ai_config_deep_links_from_args(&startup_args); + open_ai_config_deep_links(app.handle(), startup_ai_config_links); let app_handle = app.handle().clone(); commands::mcp_bridge::start(app_handle, state, data_dir); @@ -2023,6 +2039,7 @@ pub fn run() { commands::keychain::read_keychain_password, commands::keychain::read_keychain_passwords, commands::deep_link::pending_open_connection_links, + commands::deep_link::pending_open_ai_config_links, commands::table_import::preview_table_import_file, commands::table_import::import_table_file, commands::table_import::cancel_table_import, @@ -2610,6 +2627,13 @@ pub fn run() { .collect(); open_connection_deep_links(app_handle, links); + let ai_config_links: Vec = urls + .iter() + .map(|url| url.to_string()) + .filter_map(|url| commands::deep_link::ai_config_deep_link_from_arg(&url)) + .collect(); + open_ai_config_deep_links(app_handle, ai_config_links); + let paths: Vec = urls .iter() .filter_map(|url| url.to_file_path().ok())