feat(ai): add generic AI configuration deep links
This commit is contained in:
13 files changed
+503
-13
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user