feat(ai): add generic AI configuration deep links

This commit is contained in:
t8y2 committed 2026-08-24 06:05:24 +08:00
1 parent 3ece54d07e
commit e11a0d467d
13 files changed
+503 -13

No files matched your search

+36
View File
@@ -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<string | undefined>(undefined);
const settingsNavigationRequestId = ref(0);
const settingsAiConfigDraft = ref<AiConfigDeepLinkDraft | null>(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"
@@ -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<string | null>(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") {
+18 -1
View File
@@ -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<void>; openSqlFilePath: (path: string) => Promise<void>; openDbFilePath: (path: string) => Promise<void>; openConnectionDeepLink: (url: string) => Promise<void> }) {
export function useTauriEvents(deps: {
openTableTarget: (target: NavigationTarget) => Promise<void>;
openSqlFilePath: (path: string) => Promise<void>;
openDbFilePath: (path: string) => Promise<void>;
openConnectionDeepLink: (url: string) => Promise<void>;
openAiConfigDeepLink: (url: string) => Promise<void>;
}) {
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<string[]>("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(() => {});
}
+6
View File
@@ -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",
+6
View File
@@ -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: "新增配置",
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import { clipboardApiKeyCandidate, importClipboardApiKeyAfterConfirmation, parseAiConfigDeepLink } from "@/lib/ai/aiConfigDeepLink";
function buildLink(params: Record<string, string>): 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);
});
});
+131
View File
@@ -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<AiProvider>(["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<string>();
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<boolean>, readClipboard: () => Promise<string>): Promise<ClipboardApiKeyImportResult> {
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" };
}
+1
View File
@@ -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");
+4
View File
@@ -2139,6 +2139,10 @@ export async function pendingOpenConnectionLinks(): Promise<string[]> {
return [];
}
export async function pendingOpenAiConfigLinks(): Promise<string[]> {
return [];
}
export async function readExternalSqlFile(_path: string): Promise<string> {
throw new Error("Opening external SQL file paths is only available in the desktop app");
}
+4
View File
@@ -825,6 +825,10 @@ export async function pendingOpenConnectionLinks(): Promise<string[]> {
return invoke("pending_open_connection_links");
}
export async function pendingOpenAiConfigLinks(): Promise<string[]> {
return invoke("pending_open_ai_config_links");
}
export interface ExternalSqlFileSnapshot {
content: string;
version: ExternalSqlFileVersion;
@@ -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(");
});
});