mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(ai): support multiple configurations and models
This commit is contained in:
@@ -1765,7 +1765,7 @@ function onLoginSuccess() {
|
||||
async function initApp() {
|
||||
const t0 = performance.now();
|
||||
console.log("[STARTUP] initApp begin");
|
||||
settingsStore.initAiConfig();
|
||||
await settingsStore.initAiConfigs();
|
||||
try {
|
||||
await settingsStore.initEditorSettings();
|
||||
console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
|
||||
@@ -41,10 +41,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore, AI_PROVIDER_PRESETS, type AiProvider } from "@/stores/settingsStore";
|
||||
import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stores/settingsStore";
|
||||
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
@@ -55,7 +54,7 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext } from "@/lib/ai/ai";
|
||||
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
|
||||
|
||||
import type { AgentEvent } from "@/lib/backend/tauri";
|
||||
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
|
||||
import { extractFirstSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||
@@ -65,7 +64,7 @@ import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentSt
|
||||
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
|
||||
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
|
||||
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
|
||||
import { aiCancelStream, aiListModels, saveAiConversation, loadAiConversations, deleteAiConversation, listSchemas, listTables, type AiConversation, type AiModelInfo } from "@/lib/backend/api";
|
||||
import { aiCancelStream, saveAiConversation, loadAiConversations, deleteAiConversation, listSchemas, listTables, type AiConversation } from "@/lib/backend/api";
|
||||
import type { AiMessage } from "@/lib/backend/api";
|
||||
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
@@ -198,7 +197,7 @@ function submitEdit(visibleIndex: number) {
|
||||
const actualIndex = visibleToActualIndex(messages.value, visibleIndex);
|
||||
if (actualIndex < 0) return;
|
||||
if (!props.connection || !props.tab) return;
|
||||
if (!settings.isConfigured()) {
|
||||
if (!settings.isConfigured) {
|
||||
toast(t("ai.noConfig"));
|
||||
return;
|
||||
}
|
||||
@@ -225,72 +224,63 @@ function onEditKeydown(event: KeyboardEvent, visibleIndex: number) {
|
||||
}
|
||||
|
||||
// Inline model selector
|
||||
const modelOptions = ref<AiModelInfo[]>([]);
|
||||
const modelLoading = ref(false);
|
||||
let modelRequestToken = 0;
|
||||
const providerSelectorOpen = ref(false);
|
||||
const modelSearchQuery = ref("");
|
||||
|
||||
// Configured providers for quick switching
|
||||
const configuredProviders = computed(() => (Object.keys(AI_PROVIDER_PRESETS) as AiProvider[]).filter((p) => p !== settings.aiConfig.provider && settings.isAiProviderConfigured(p)));
|
||||
|
||||
function handleProviderSwitch(provider: AiProvider) {
|
||||
settings.updateAiConfig({ provider });
|
||||
modelOptions.value = [];
|
||||
providerSelectorOpen.value = false;
|
||||
}
|
||||
|
||||
function normalizeModelOptions(models: AiModelInfo[]): AiModelInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: AiModelInfo[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id?.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
normalized.push({ id, displayName: model.displayName?.trim() || undefined });
|
||||
// Configured providers for quick switching - get from aiConfigs
|
||||
const configuredProviders = computed(() => {
|
||||
const providers = settings.aiConfigs.filter((c) => {
|
||||
// Check directly if config has required fields
|
||||
const preset = AI_PROVIDER_PRESETS[c.provider];
|
||||
if (c.provider === "codex-cli") return true;
|
||||
return !!c.endpoint?.trim() && !!c.model?.trim() && (!preset.requiresApiKey || !!c.apiKey?.trim());
|
||||
});
|
||||
// Apply search filter - hide providers with no matching models
|
||||
if (modelSearchQuery.value.trim()) {
|
||||
const query = modelSearchQuery.value.trim().toLowerCase();
|
||||
return providers.filter((c) => {
|
||||
const models = getModelsForConfig(c.id);
|
||||
return models.some((model) => model.toLowerCase().includes(query));
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function fetchModelOptions() {
|
||||
if (modelLoading.value) return;
|
||||
if (!settings.isConfigured()) return;
|
||||
const token = ++modelRequestToken;
|
||||
modelLoading.value = true;
|
||||
try {
|
||||
const models = normalizeModelOptions(await aiListModels(settings.aiConfig));
|
||||
if (token !== modelRequestToken) return;
|
||||
modelOptions.value = models;
|
||||
} catch {
|
||||
if (token !== modelRequestToken) return;
|
||||
modelOptions.value = [];
|
||||
} finally {
|
||||
if (token === modelRequestToken) modelLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleModelSelect(modelId: string) {
|
||||
settings.updateAiConfig({ model: modelId });
|
||||
}
|
||||
|
||||
const modelOptionIds = computed(() => {
|
||||
const currentModel = settings.aiConfig.model;
|
||||
const ids = modelOptions.value.map((model) => model.id);
|
||||
if (currentModel && !ids.includes(currentModel)) {
|
||||
return [currentModel, ...ids];
|
||||
}
|
||||
return ids;
|
||||
return providers;
|
||||
});
|
||||
|
||||
function displayModelName(modelId: string) {
|
||||
return modelOptions.value.find((model) => model.id === modelId)?.displayName || modelId;
|
||||
const activeFullConfig = computed(() => {
|
||||
if (!settings.activeModel) return null;
|
||||
const item = settings.aiConfigs.find((c) => c.id === settings.activeModel!.configId);
|
||||
if (!item) return null;
|
||||
return normalizeAiConfig({ ...item, model: settings.activeModel!.modelId });
|
||||
});
|
||||
|
||||
function getModelsForConfig(configId: string): string[] {
|
||||
const config = settings.aiConfigs.find((c) => c.id === configId);
|
||||
if (!config) return [];
|
||||
const models = config.models?.map((m) => m.name) || [];
|
||||
// Always include the current model
|
||||
if (config.model && !models.includes(config.model)) {
|
||||
return [config.model, ...models];
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function modelOptionPresentation(modelId: string, label = displayModelName(modelId)) {
|
||||
return formatAiModelOption(label, modelId);
|
||||
function getConfigModelOptionIds(configId: string): string[] {
|
||||
const config = settings.aiConfigs.find((c) => c.id === configId);
|
||||
if (!config) return [];
|
||||
let models = getModelsForConfig(configId);
|
||||
// Apply search filter
|
||||
if (modelSearchQuery.value.trim()) {
|
||||
const query = modelSearchQuery.value.trim().toLowerCase();
|
||||
models = models.filter((model) => model.toLowerCase().includes(query));
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function modelOptionSecondary(modelId: string, label = displayModelName(modelId)) {
|
||||
return modelOptionPresentation(modelId, label).secondary;
|
||||
function handleModelSelect(configId: string, modelId: string) {
|
||||
const config = settings.aiConfigs.find((c) => c.id === configId);
|
||||
if (!config) return;
|
||||
settings.updateActiveModel({ configId, modelId });
|
||||
providerSelectorOpen.value = false;
|
||||
}
|
||||
|
||||
/** Deferred context compaction info; applied after stream ends to avoid shifting assistantIdx. */
|
||||
@@ -1384,7 +1374,7 @@ async function send() {
|
||||
if ((!text && !selectedMentions.value.length && !selectedSqlFileMentions.value.length) || isGenerating.value) return;
|
||||
|
||||
if (!props.connection || !props.tab) return;
|
||||
if (!settings.isConfigured()) {
|
||||
if (!settings.isConfigured) {
|
||||
toast(t("ai.noConfig"));
|
||||
return;
|
||||
}
|
||||
@@ -1428,7 +1418,7 @@ async function send() {
|
||||
const history: AiMessage[] = messagesForAgentHistory(messages.value.slice(0, -2));
|
||||
await runAgentStream(
|
||||
{
|
||||
config: settings.aiConfig,
|
||||
config: activeFullConfig.value!,
|
||||
action: requestedAction,
|
||||
mode: requestedMode,
|
||||
instruction: modelInstruction,
|
||||
@@ -1612,6 +1602,10 @@ async function deleteConversation(id: string) {
|
||||
function startNewChat() {
|
||||
clearMessages();
|
||||
showConversationList.value = false;
|
||||
const defaultConfig = settings.aiConfigs.find((c) => c.isDefault) || settings.aiConfigs[0];
|
||||
if (defaultConfig) {
|
||||
settings.updateActiveModel({ configId: defaultConfig.id, modelId: defaultConfig.model });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -1628,9 +1622,6 @@ onMounted(async () => {
|
||||
appearance: () => aiCodeAppearance.value,
|
||||
}).catch(() => undefined);
|
||||
|
||||
// Load available AI models for inline selector
|
||||
fetchModelOptions();
|
||||
|
||||
window.addEventListener("resize", handlePanelResize);
|
||||
if (typeof ResizeObserver !== "undefined" && assistantRootRef.value) {
|
||||
promptPanelResizeObserver = new ResizeObserver(handlePanelResize);
|
||||
@@ -2123,57 +2114,66 @@ async function openExternalUrl(url: string) {
|
||||
@update:model-value="(value) => selectAction(value as AiAction)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1" />
|
||||
<template v-if="settings.isConfigured()">
|
||||
<template v-if="settings.aiConfigs.length > 0">
|
||||
<!-- Combined provider + model selector -->
|
||||
<Popover v-model:open="providerSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="min-w-0 flex shrink items-center gap-1.5 max-w-[220px] rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<AiProviderLogo :provider="settings.aiConfig.provider" :label="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.label ?? settings.aiConfig.provider" :icon-slug="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.iconSlug" class="h-3 w-3 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ modelLoading ? t("ai.loadingModels") : settings.aiConfig.model }}</span>
|
||||
<AiProviderLogo
|
||||
:provider="activeFullConfig?.provider ?? 'claude'"
|
||||
:label="AI_PROVIDER_PRESETS[activeFullConfig?.provider ?? 'claude']?.label ?? activeFullConfig?.provider ?? 'claude'"
|
||||
:icon-slug="AI_PROVIDER_PRESETS[activeFullConfig?.provider ?? 'claude']?.iconSlug"
|
||||
class="h-3 w-3 shrink-0"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ activeFullConfig?.model || t("ai.selectModel") }}</span>
|
||||
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 gap-0 p-1.5" @open-auto-focus.prevent>
|
||||
<!-- Configured providers section -->
|
||||
<template v-if="configuredProviders.length">
|
||||
<p class="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{{ t("ai.switchProvider") }}</p>
|
||||
<button v-for="p in configuredProviders" :key="p" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground" @click="handleProviderSwitch(p)">
|
||||
<AiProviderLogo :provider="p" :label="AI_PROVIDER_PRESETS[p]?.label ?? p" :icon-slug="AI_PROVIDER_PRESETS[p]?.iconSlug" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="font-medium">{{ AI_PROVIDER_PRESETS[p]?.label ?? p }}</span>
|
||||
<span class="ml-auto min-w-0 truncate text-[11px] text-muted-foreground">{{ settings.aiProviderConfigs[p]?.model }}</span>
|
||||
</button>
|
||||
<div class="my-1 border-t" />
|
||||
</template>
|
||||
<!-- Model list for current provider -->
|
||||
<p class="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{{ AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.label ?? settings.aiConfig.provider }}
|
||||
</p>
|
||||
<SearchableSelect
|
||||
:model-value="settings.aiConfig.model"
|
||||
:options="modelOptionIds"
|
||||
:placeholder="t('ai.browseModels')"
|
||||
:search-placeholder="t('ai.searchModels')"
|
||||
:empty-text="t('ai.modelListHint')"
|
||||
:loading-text="t('ai.loadingModels')"
|
||||
:loading="modelLoading"
|
||||
:display-name="displayModelName"
|
||||
trigger-class="w-full max-w-full justify-start rounded-sm px-2 py-1.5 text-xs text-foreground hover:bg-accent"
|
||||
content-class="w-72"
|
||||
item-class="h-auto min-h-8 px-2 py-1.5 text-xs"
|
||||
@update:model-value="handleModelSelect"
|
||||
@update:open="(open: boolean) => open && fetchModelOptions()"
|
||||
>
|
||||
<template #trigger-label="{ label, loading }">
|
||||
<AiProviderLogo :provider="settings.aiConfig.provider" :label="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.label ?? settings.aiConfig.provider" :icon-slug="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.iconSlug" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ loading ? t("ai.loadingModels") : label }}</span>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-80 gap-0 p-1.5"
|
||||
@open-auto-focus.prevent
|
||||
@update:open="
|
||||
(open: boolean) => {
|
||||
if (!open) modelSearchQuery = '';
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Search input -->
|
||||
<div class="relative px-1 pb-1">
|
||||
<Search class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input v-model="modelSearchQuery" type="text" :placeholder="t('ai.searchModels')" class="w-full rounded-sm border bg-background py-1.5 pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-primary" @click.stop />
|
||||
</div>
|
||||
<!-- All configured providers with their models -->
|
||||
<div class="max-h-80 overflow-auto">
|
||||
<template v-for="config in configuredProviders" :key="config.id">
|
||||
<!-- Provider header -->
|
||||
<div class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-xs" :class="config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : 'text-foreground'">
|
||||
<AiProviderLogo :provider="config.provider" :label="AI_PROVIDER_PRESETS[config.provider]?.label ?? config.provider" :icon-slug="AI_PROVIDER_PRESETS[config.provider]?.iconSlug" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="font-medium">{{ config.name }}</span>
|
||||
<span v-if="config.isDefault" class="ml-auto text-[10px] text-muted-foreground">{{ t("ai.default") }}</span>
|
||||
</div>
|
||||
<!-- No models hint -->
|
||||
<div v-if="!getConfigModelOptionIds(config.id).length" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
{{ t("ai.noModel") }}
|
||||
</div>
|
||||
<!-- Model list -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="modelId in getConfigModelOptionIds(config.id)"
|
||||
:key="modelId"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
:class="modelId === settings.activeModel?.modelId && config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : ''"
|
||||
@click="handleModelSelect(config.id, modelId)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ modelId }}</span>
|
||||
<Check v-if="modelId === settings.activeModel?.modelId && config.id === settings.activeModel?.configId" class="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
</button>
|
||||
</template>
|
||||
<div class="my-1 border-t" />
|
||||
</template>
|
||||
<template #option-label="{ option, label }">
|
||||
<span class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate">{{ modelOptionPresentation(option, label).primary }}</span>
|
||||
<span v-if="modelOptionSecondary(option, label)" class="mt-0.5 truncate text-[11px] text-muted-foreground">{{ modelOptionSecondary(option, label) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } fr
|
||||
import type { Ref } from "vue";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { AlertTriangle, ArrowLeft, Check, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -44,9 +44,9 @@ import {
|
||||
type CustomTheme,
|
||||
} from "@/stores/settingsStore";
|
||||
import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
|
||||
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
|
||||
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
|
||||
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
|
||||
import DangerConfirmDialog from "./DangerConfirmDialog.vue";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
@@ -74,7 +74,6 @@ import {
|
||||
webdavSyncTest,
|
||||
webdavSyncUpload,
|
||||
type AppSupportInfo,
|
||||
type AiModelInfo,
|
||||
type McpServerStatus,
|
||||
type SnippetProvider,
|
||||
type SnippetSyncConfig,
|
||||
@@ -86,6 +85,7 @@ import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { validateConfigName, generateId, type AiConfigItem, type ConfigNameValidationResult } from "@/lib/ai/aiConfigList";
|
||||
import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "@/lib/table/tableColumnTemplates";
|
||||
@@ -1610,6 +1610,7 @@ async function downloadSnippetSnapshot() {
|
||||
// Snapshot downloads replace backend-managed tunnel profiles, so refresh
|
||||
// the already-loaded Pinia store instead of leaving the UI stale.
|
||||
await tunnelProfileStore.refresh();
|
||||
await settingsStore.reloadAiConfigs();
|
||||
let message = t("settings.syncSnippetDownloadSuccess", { bytes: result.summary.bytes, id: result.summary.snippetId });
|
||||
if (result.applySummary.encryptedSecretsPresent && !result.applySummary.secretsApplied) message += ` ${t("settings.syncSecretsSkipped")}`;
|
||||
if (result.applySummary.secretsApplied) message += ` ${t("settings.syncSecretsApplied")}`;
|
||||
@@ -1748,6 +1749,7 @@ async function downloadWebDavSnapshot() {
|
||||
await savedSqlStore.initFromStorage();
|
||||
// Keep the shared tunnel profile UI consistent with the downloaded snapshot.
|
||||
await tunnelProfileStore.refresh();
|
||||
await settingsStore.reloadAiConfigs();
|
||||
const message = t("settings.syncDownloadSuccess", {
|
||||
bytes: result.summary.bytes,
|
||||
path: result.summary.remotePath,
|
||||
@@ -1780,12 +1782,14 @@ watch(
|
||||
() => settingsVisible.value,
|
||||
async (open) => {
|
||||
if (open) {
|
||||
aiConfigListMode.value = "list";
|
||||
aiEditConfigId.value = null;
|
||||
activeSettingsTab.value = props.initialTab || "appearance";
|
||||
passwordMessage.value = "";
|
||||
oldPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmNewPassword.value = "";
|
||||
await settingsStore.initAiConfig();
|
||||
await settingsStore.initAiConfigs();
|
||||
await settingsStore.initDesktopSettings();
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
|
||||
@@ -1907,28 +1911,43 @@ async function changePassword() {
|
||||
}
|
||||
|
||||
// ---------- AI Settings ----------
|
||||
// AI Config List Mode
|
||||
const aiConfigListMode = ref<"list" | "edit">("list");
|
||||
const aiEditConfigName = ref("");
|
||||
const aiEditConfigId = ref<string | null>(null);
|
||||
|
||||
// AI Config Delete Confirmation
|
||||
const aiDeleteConfirmOpen = ref(false);
|
||||
const aiDeleteConfigId = ref<string | null>(null);
|
||||
|
||||
// Model list management
|
||||
const aiEditModels = ref<Array<{ name: string; label?: string }>>([]);
|
||||
const aiModelListLoading = ref(false);
|
||||
let aiModelListRequestToken = 0;
|
||||
|
||||
// AI Model Multi-Select
|
||||
const aiModelMultiSelectOpen = ref(false);
|
||||
const aiModelMultiSelectSearch = ref("");
|
||||
const aiFetchedModels = ref<Array<{ id: string; displayName?: string }>>([]);
|
||||
|
||||
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || provider.provider !== "codex-cli"));
|
||||
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
|
||||
|
||||
const aiEditProvider = ref<AiProvider>(settingsStore.aiConfig.provider);
|
||||
const aiEditApiKey = ref(settingsStore.aiConfig.apiKey);
|
||||
const aiEditAuthMethod = ref<AiAuthMethod>(settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[settingsStore.aiConfig.provider].authMethod);
|
||||
const aiEditEndpoint = ref(settingsStore.aiConfig.endpoint);
|
||||
const aiEditModel = ref(settingsStore.aiConfig.model);
|
||||
const aiEditApiStyle = ref<AiApiStyle>(settingsStore.aiConfig.apiStyle || "completions");
|
||||
const aiEditProxyEnabled = ref(!!settingsStore.aiConfig.proxyEnabled);
|
||||
const aiEditProxyUrl = ref(settingsStore.aiConfig.proxyUrl || "");
|
||||
const aiEditEnableThinking = ref(settingsStore.aiConfig.enableThinking ?? true);
|
||||
const aiEditReasoningLevel = ref<AiReasoningLevel>(settingsStore.aiConfig.reasoningLevel || "default");
|
||||
const aiEditContextWindow = ref<number | undefined>(settingsStore.aiConfig.contextWindow);
|
||||
const aiEditCodexCliPath = ref(settingsStore.aiConfig.codexCliPath || "");
|
||||
const aiEditCodexCliEnvRows = ref<AiEnvRow[]>(aiEnvRowsFromConfig(settingsStore.aiConfig.codexCliEnv));
|
||||
const aiEditProvider = ref<AiProvider>("claude");
|
||||
const aiEditApiKey = ref("");
|
||||
const aiEditAuthMethod = ref<AiAuthMethod>("api-key");
|
||||
const aiEditEndpoint = ref("");
|
||||
const aiEditModel = ref("");
|
||||
const aiEditApiStyle = ref<AiApiStyle>("completions");
|
||||
const aiEditProxyEnabled = ref(false);
|
||||
const aiEditProxyUrl = ref("");
|
||||
const aiEditEnableThinking = ref(true);
|
||||
const aiEditReasoningLevel = ref<AiReasoningLevel>("default");
|
||||
const aiEditContextWindow = ref<number | undefined>(undefined);
|
||||
const aiEditCodexCliPath = ref("");
|
||||
const aiEditCodexCliEnvRows = ref<AiEnvRow[]>([]);
|
||||
|
||||
const aiModelOptions = ref<AiModelInfo[]>([]);
|
||||
const aiModelLoading = ref(false);
|
||||
const aiModelError = ref("");
|
||||
const aiModelLoadedSignature = ref("");
|
||||
let aiModelRequestToken = 0;
|
||||
|
||||
const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions");
|
||||
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
|
||||
@@ -1989,12 +2008,6 @@ const aiCodexMcpActionLabel = computed(() => {
|
||||
});
|
||||
const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini");
|
||||
const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCodexCli.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
|
||||
const aiModelOptionIds = computed(() => aiModelOptions.value.map((model) => model.id));
|
||||
const aiModelEmptyText = computed(() => {
|
||||
if (aiModelError.value) return aiModelError.value;
|
||||
if (!aiModelListSupported.value) return t("ai.modelListUnsupported");
|
||||
return t("ai.noModels");
|
||||
});
|
||||
const aiCodexEnvError = computed(() => codexEnvValidationError());
|
||||
const aiCodexPathError = computed(() => {
|
||||
const path = aiEditCodexCliPath.value.trim();
|
||||
@@ -2017,14 +2030,6 @@ function codexEnvFromRows(): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function codexEnvSignature(): string {
|
||||
return JSON.stringify(Object.entries(codexEnvFromRows()).sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
function savedCodexEnvSignature(): string {
|
||||
return JSON.stringify(Object.entries(normalizeAiEnv(settingsStore.aiConfig.codexCliEnv)).sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
function codexEnvValidationError(): string {
|
||||
for (const row of aiEditCodexCliEnvRows.value) {
|
||||
const key = row.key.trim();
|
||||
@@ -2042,27 +2047,6 @@ function removeCodexEnvRow(id: string) {
|
||||
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
|
||||
}
|
||||
|
||||
function clearAiModelOptions() {
|
||||
aiModelRequestToken += 1;
|
||||
aiModelOptions.value = [];
|
||||
aiModelError.value = "";
|
||||
aiModelLoadedSignature.value = "";
|
||||
aiModelLoading.value = false;
|
||||
}
|
||||
|
||||
function aiModelConfigSignature() {
|
||||
return JSON.stringify({
|
||||
provider: aiEditProvider.value,
|
||||
endpoint: aiEditEndpoint.value.trim(),
|
||||
apiKey: aiEditApiKey.value.trim(),
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
proxyEnabled: aiEditProxyEnabled.value,
|
||||
proxyUrl: aiEditProxyUrl.value.trim(),
|
||||
codexCliPath: aiEditCodexCliPath.value.trim(),
|
||||
codexCliEnv: codexEnvSignature(),
|
||||
});
|
||||
}
|
||||
|
||||
function currentAiEditConfig() {
|
||||
return {
|
||||
provider: aiEditProvider.value,
|
||||
@@ -2081,107 +2065,27 @@ function currentAiEditConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAiModelOptions(models: AiModelInfo[]): AiModelInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: AiModelInfo[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id?.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
normalized.push({ id, displayName: model.displayName?.trim() || undefined });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function displayAiModelName(modelId: string): string {
|
||||
return aiModelOptions.value.find((model) => model.id === modelId)?.displayName || modelId;
|
||||
}
|
||||
|
||||
function aiModelOptionPresentation(modelId: string, label = displayAiModelName(modelId)) {
|
||||
return formatAiModelOption(label, modelId);
|
||||
}
|
||||
|
||||
function aiModelOptionSecondary(modelId: string, label = displayAiModelName(modelId)) {
|
||||
return aiModelOptionPresentation(modelId, label).secondary;
|
||||
}
|
||||
|
||||
async function aiRefreshModels() {
|
||||
if (aiModelLoading.value) return;
|
||||
if (!aiModelListSupported.value) {
|
||||
aiModelError.value = t("ai.modelListUnsupported");
|
||||
return;
|
||||
}
|
||||
if (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) {
|
||||
aiModelError.value = t("ai.modelListEndpointRequired");
|
||||
return;
|
||||
}
|
||||
if (aiRequiresApiKey.value && !aiEditApiKey.value.trim()) {
|
||||
aiModelError.value = t("ai.modelListApiKeyRequired");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++aiModelRequestToken;
|
||||
const signature = aiModelConfigSignature();
|
||||
aiModelLoading.value = true;
|
||||
aiModelError.value = "";
|
||||
try {
|
||||
const models = normalizeAiModelOptions(await aiListModels(currentAiEditConfig()));
|
||||
if (token !== aiModelRequestToken) return;
|
||||
aiModelOptions.value = models;
|
||||
aiModelLoadedSignature.value = signature;
|
||||
if (!aiEditModel.value.trim() && models[0]) aiEditModel.value = models[0].id;
|
||||
} catch (e: any) {
|
||||
if (token !== aiModelRequestToken) return;
|
||||
aiModelOptions.value = [];
|
||||
aiModelError.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (token === aiModelRequestToken) aiModelLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAiModelListOpen(open: boolean) {
|
||||
if (open && aiCanListModels.value && !aiModelLoading.value && (!aiModelOptions.value.length || aiModelLoadedSignature.value !== aiModelConfigSignature())) {
|
||||
void aiRefreshModels();
|
||||
}
|
||||
}
|
||||
|
||||
function aiSelectModel(modelId: string) {
|
||||
aiEditModel.value = modelId;
|
||||
}
|
||||
|
||||
function syncAiEditState() {
|
||||
const provider = isWeb && settingsStore.aiConfig.provider === "codex-cli" ? "claude" : settingsStore.aiConfig.provider;
|
||||
aiEditProvider.value = provider;
|
||||
aiEditApiKey.value = settingsStore.aiConfig.apiKey;
|
||||
aiEditAuthMethod.value = settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[provider].authMethod;
|
||||
aiEditEndpoint.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.endpoint : AI_PROVIDER_PRESETS[provider].endpoint;
|
||||
aiEditModel.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.model : AI_PROVIDER_PRESETS[provider].model;
|
||||
aiEditApiStyle.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.apiStyle || "completions" : AI_PROVIDER_PRESETS[provider].apiStyle;
|
||||
aiEditProxyEnabled.value = !!settingsStore.aiConfig.proxyEnabled;
|
||||
aiEditProxyUrl.value = settingsStore.aiConfig.proxyUrl || "";
|
||||
aiEditEnableThinking.value = settingsStore.aiConfig.enableThinking ?? true;
|
||||
aiEditReasoningLevel.value = settingsStore.aiConfig.reasoningLevel || "default";
|
||||
aiEditContextWindow.value = settingsStore.aiConfig.contextWindow;
|
||||
aiEditCodexCliPath.value = settingsStore.aiConfig.codexCliPath || "";
|
||||
aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(settingsStore.aiConfig.codexCliEnv);
|
||||
aiTestResult.value = "";
|
||||
aiTestError.value = "";
|
||||
aiTestLatency.value = null;
|
||||
aiTestErrorCopied.value = false;
|
||||
clearAiModelOptions();
|
||||
}
|
||||
|
||||
function aiSelectProvider(provider: AiProvider) {
|
||||
if (isWeb && provider === "codex-cli") return;
|
||||
if (provider === aiEditProvider.value) return;
|
||||
|
||||
// Save current form edits before switching to prevent data loss.
|
||||
if (aiHasChanges()) {
|
||||
settingsStore.updateAiConfig(currentAiEditConfig());
|
||||
}
|
||||
|
||||
settingsStore.updateAiConfig({ provider });
|
||||
syncAiEditState();
|
||||
// Apply new provider's preset defaults to edit state
|
||||
const preset = AI_PROVIDER_PRESETS[provider];
|
||||
aiEditProvider.value = provider;
|
||||
aiEditApiKey.value = "";
|
||||
aiEditAuthMethod.value = preset.authMethod;
|
||||
aiEditEndpoint.value = preset.endpoint;
|
||||
aiEditModel.value = preset.model;
|
||||
aiEditApiStyle.value = preset.apiStyle;
|
||||
aiEditModels.value = [];
|
||||
aiFetchedModels.value = [];
|
||||
if (provider === "codex-cli") void ensureCodexMcpStatus();
|
||||
}
|
||||
|
||||
@@ -2192,31 +2096,215 @@ function aiSelectApiStyle(style: AiApiStyle) {
|
||||
}
|
||||
}
|
||||
|
||||
function aiHasChanges(): boolean {
|
||||
return (
|
||||
aiEditProvider.value !== settingsStore.aiConfig.provider ||
|
||||
aiEditApiKey.value !== settingsStore.aiConfig.apiKey ||
|
||||
aiEditAuthMethod.value !== (settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[settingsStore.aiConfig.provider].authMethod) ||
|
||||
aiEditEndpoint.value !== settingsStore.aiConfig.endpoint ||
|
||||
aiEditModel.value !== settingsStore.aiConfig.model ||
|
||||
aiEditApiStyle.value !== (settingsStore.aiConfig.apiStyle || "completions") ||
|
||||
aiEditProxyEnabled.value !== !!settingsStore.aiConfig.proxyEnabled ||
|
||||
aiEditProxyUrl.value !== (settingsStore.aiConfig.proxyUrl || "") ||
|
||||
aiEditEnableThinking.value !== (settingsStore.aiConfig.enableThinking ?? true) ||
|
||||
aiEditReasoningLevel.value !== (settingsStore.aiConfig.reasoningLevel || "default") ||
|
||||
aiEditContextWindow.value !== settingsStore.aiConfig.contextWindow ||
|
||||
aiEditCodexCliPath.value !== (settingsStore.aiConfig.codexCliPath || "") ||
|
||||
codexEnvSignature() !== savedCodexEnvSignature()
|
||||
);
|
||||
function aiEnterListMode() {
|
||||
aiConfigListMode.value = "list";
|
||||
aiEditConfigId.value = null;
|
||||
}
|
||||
|
||||
function aiApplySettings() {
|
||||
if (aiCodexValidationError.value) {
|
||||
aiTestResult.value = "error";
|
||||
aiTestError.value = aiCodexValidationError.value;
|
||||
function aiEnterEditMode(configId?: string) {
|
||||
aiConfigListMode.value = "edit";
|
||||
aiEditConfigId.value = configId || null;
|
||||
|
||||
if (configId) {
|
||||
const config = settingsStore.aiConfigs.find((c) => c.id === configId);
|
||||
if (config) {
|
||||
aiEditConfigName.value = config.name;
|
||||
aiEditProvider.value = config.provider;
|
||||
aiEditApiKey.value = config.apiKey;
|
||||
aiEditAuthMethod.value = config.authMethod;
|
||||
aiEditEndpoint.value = config.endpoint;
|
||||
aiEditModel.value = config.model;
|
||||
aiEditApiStyle.value = config.apiStyle;
|
||||
aiEditProxyEnabled.value = config.proxyEnabled ?? false;
|
||||
aiEditProxyUrl.value = config.proxyUrl ?? "";
|
||||
aiEditEnableThinking.value = config.enableThinking ?? true;
|
||||
aiEditReasoningLevel.value = config.reasoningLevel ?? "default";
|
||||
aiEditContextWindow.value = config.contextWindow;
|
||||
aiEditCodexCliPath.value = config.codexCliPath ?? "";
|
||||
aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv);
|
||||
aiEditModels.value = config.models ? [...config.models] : [];
|
||||
}
|
||||
} else {
|
||||
aiEditConfigName.value = "";
|
||||
aiEditModels.value = [];
|
||||
aiEditProvider.value = "claude";
|
||||
aiEditApiKey.value = "";
|
||||
aiEditAuthMethod.value = AI_PROVIDER_PRESETS["claude"].authMethod;
|
||||
aiEditEndpoint.value = AI_PROVIDER_PRESETS["claude"].endpoint;
|
||||
aiEditModel.value = AI_PROVIDER_PRESETS["claude"].model;
|
||||
aiEditApiStyle.value = AI_PROVIDER_PRESETS["claude"].apiStyle;
|
||||
aiEditProxyEnabled.value = false;
|
||||
aiEditProxyUrl.value = "";
|
||||
aiEditEnableThinking.value = true;
|
||||
aiEditReasoningLevel.value = "default";
|
||||
aiEditContextWindow.value = undefined;
|
||||
aiEditCodexCliPath.value = "";
|
||||
aiEditCodexCliEnvRows.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function aiAddModel() {
|
||||
aiEditModels.value.push({ name: "", label: "" });
|
||||
}
|
||||
|
||||
function aiRemoveModel(index: number) {
|
||||
aiEditModels.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function aiFetchModelList() {
|
||||
if (aiModelListLoading.value) return;
|
||||
if (!aiCanListModels.value) return;
|
||||
const token = ++aiModelListRequestToken;
|
||||
aiModelListLoading.value = true;
|
||||
aiModelError.value = "";
|
||||
try {
|
||||
const models = await aiListModels({
|
||||
provider: aiEditProvider.value,
|
||||
apiKey: aiEditApiKey.value,
|
||||
endpoint: aiEditEndpoint.value,
|
||||
model: aiEditModel.value,
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
apiStyle: aiEditApiStyle.value,
|
||||
proxyEnabled: aiEditProxyEnabled.value,
|
||||
proxyUrl: aiEditProxyUrl.value,
|
||||
enableThinking: aiEditEnableThinking.value,
|
||||
reasoningLevel: aiEditReasoningLevel.value,
|
||||
contextWindow: aiEditContextWindow.value,
|
||||
codexCliPath: aiEditCodexCliPath.value,
|
||||
codexCliEnv: codexEnvFromRows(),
|
||||
});
|
||||
if (token !== aiModelListRequestToken) return;
|
||||
const seen = new Set<string>();
|
||||
aiFetchedModels.value = models
|
||||
.filter((m: any) => {
|
||||
const id = m.id?.trim();
|
||||
if (!id || seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
})
|
||||
.map((m: any) => ({ id: m.id.trim(), displayName: m.displayName?.trim() || undefined }));
|
||||
} catch (e: any) {
|
||||
if (token === aiModelListRequestToken) {
|
||||
aiModelError.value = e?.message || String(e);
|
||||
}
|
||||
} finally {
|
||||
if (token === aiModelListRequestToken) aiModelListLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function aiIsModelSelected(modelId: string): boolean {
|
||||
return aiEditModels.value.some((m) => m.name === modelId);
|
||||
}
|
||||
|
||||
function aiToggleModel(model: { id: string; displayName?: string }) {
|
||||
const index = aiEditModels.value.findIndex((m) => m.name === model.id);
|
||||
if (index >= 0) {
|
||||
aiEditModels.value.splice(index, 1);
|
||||
} else {
|
||||
aiEditModels.value.push({ name: model.id, label: model.displayName || model.id });
|
||||
}
|
||||
}
|
||||
|
||||
const aiFilteredFetchedModels = computed(() => {
|
||||
const search = aiModelMultiSelectSearch.value.trim().toLowerCase();
|
||||
if (!search) return aiFetchedModels.value;
|
||||
return aiFetchedModels.value.filter((m) => m.id.toLowerCase().includes(search) || (m.displayName && m.displayName.toLowerCase().includes(search)));
|
||||
});
|
||||
|
||||
const aiModelFetchSignature = computed(() =>
|
||||
JSON.stringify({
|
||||
provider: aiEditProvider.value,
|
||||
endpoint: aiEditEndpoint.value.trim(),
|
||||
apiKey: aiEditApiKey.value.trim(),
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
apiStyle: aiEditApiStyle.value,
|
||||
}),
|
||||
);
|
||||
|
||||
let aiLastModelFetchSignature = "";
|
||||
|
||||
function aiOnModelPopoverOpen(open: boolean) {
|
||||
if (!open) return;
|
||||
if (aiModelFetchSignature.value !== aiLastModelFetchSignature) {
|
||||
aiFetchedModels.value = [];
|
||||
aiLastModelFetchSignature = aiModelFetchSignature.value;
|
||||
if (aiCanListModels.value) void aiFetchModelList();
|
||||
} else if (aiFetchedModels.value.length === 0 && aiCanListModels.value) {
|
||||
void aiFetchModelList();
|
||||
}
|
||||
}
|
||||
|
||||
async function aiSaveConfig() {
|
||||
const validationResult: ConfigNameValidationResult = validateConfigName(aiEditConfigName.value, settingsStore.aiConfigs, aiEditConfigId.value || undefined);
|
||||
if (validationResult === "empty") {
|
||||
toast(t("ai.configNameEmpty"), 3000);
|
||||
return;
|
||||
}
|
||||
settingsStore.updateAiConfig(currentAiEditConfig());
|
||||
if (validationResult === "duplicate") {
|
||||
toast(t("ai.configNameExists", { name: aiEditConfigName.value }), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: AiConfigItem = {
|
||||
id: aiEditConfigId.value || generateId(),
|
||||
name: aiEditConfigName.value,
|
||||
provider: aiEditProvider.value,
|
||||
apiKey: aiEditApiKey.value,
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
endpoint: aiEditEndpoint.value,
|
||||
model: aiEditModel.value,
|
||||
models: aiEditModels.value.length > 0 ? aiEditModels.value.filter((m) => m.name.trim()) : undefined,
|
||||
apiStyle: aiEditApiStyle.value,
|
||||
proxyEnabled: aiEditProxyEnabled.value,
|
||||
proxyUrl: aiEditProxyUrl.value,
|
||||
enableThinking: aiEditEnableThinking.value,
|
||||
reasoningLevel: aiEditReasoningLevel.value,
|
||||
contextWindow: aiEditContextWindow.value,
|
||||
codexCliPath: aiEditCodexCliPath.value,
|
||||
codexCliEnv: codexEnvFromRows(),
|
||||
};
|
||||
|
||||
try {
|
||||
if (aiEditConfigId.value) {
|
||||
await settingsStore.updateAiConfigItem(aiEditConfigId.value, config);
|
||||
} else {
|
||||
await settingsStore.createAiConfig(config);
|
||||
}
|
||||
aiEnterListMode();
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function aiDeleteConfig(id: string) {
|
||||
aiDeleteConfigId.value = id;
|
||||
aiDeleteConfirmOpen.value = true;
|
||||
}
|
||||
|
||||
async function aiConfirmDeleteConfig() {
|
||||
if (aiDeleteConfigId.value) {
|
||||
if (settingsStore.activeModel?.configId === aiDeleteConfigId.value) {
|
||||
toast(t("ai.cannotDeleteActiveConfig"), 5000);
|
||||
aiDeleteConfirmOpen.value = false;
|
||||
aiDeleteConfigId.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await settingsStore.deleteAiConfig(aiDeleteConfigId.value);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
}
|
||||
aiDeleteConfirmOpen.value = false;
|
||||
aiDeleteConfigId.value = null;
|
||||
}
|
||||
|
||||
async function aiSetDefaultConfig(id: string) {
|
||||
try {
|
||||
await settingsStore.setDefaultAiConfig(id);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function aiTestConn() {
|
||||
@@ -4152,7 +4240,62 @@ onUnmounted(cleanupPreviewEditor);
|
||||
|
||||
<!-- AI Settings Tab -->
|
||||
<section v-else-if="activeSettingsTab === 'ai'" class="flex flex-col gap-5 py-2">
|
||||
<div class="space-y-3">
|
||||
<!-- Config List View -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">{{ t("ai.configList") }}</h3>
|
||||
<Button type="button" size="sm" @click="aiEnterEditMode()">
|
||||
<Plus class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("ai.addConfig") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="settingsStore.aiConfigs.length === 0" class="rounded-md border border-dashed p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">{{ t("ai.noAiConfigs") }}</p>
|
||||
<Button type="button" size="sm" class="mt-2" @click="aiEnterEditMode()">
|
||||
<Plus class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("ai.addConfig") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="config in settingsStore.aiConfigs" :key="config.id" class="flex items-center justify-between rounded-md border p-3" :class="{ 'border-primary bg-primary/5': config.isDefault }">
|
||||
<div class="flex items-center gap-3">
|
||||
<AiProviderLogo :provider="config.provider" :label="config.provider" :icon-slug="AI_PROVIDER_PRESETS[config.provider].iconSlug" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{{ config.name }}</span>
|
||||
<Badge v-if="config.isDefault" variant="default" class="h-5 text-[10px]"> {{ t("ai.default") }} </Badge>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">{{ AI_PROVIDER_PRESETS[config.provider].label }} - {{ config.model }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button v-if="!config.isDefault" type="button" size="sm" variant="ghost" @click="aiSetDefaultConfig(config.id)"> {{ t("ai.setDefault") }} </Button>
|
||||
<Button type="button" size="sm" variant="ghost" @click="aiEnterEditMode(config.id)"> {{ t("common.edit") }} </Button>
|
||||
<Button v-if="!config.isDefault" type="button" size="sm" variant="ghost" class="text-destructive" @click="aiDeleteConfig(config.id)"> {{ t("common.delete") }} </Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config Edit View -->
|
||||
<div v-else class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Button type="button" variant="ghost" size="sm" @click="aiEnterListMode()">
|
||||
<ArrowLeft class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("common.back") }}
|
||||
</Button>
|
||||
<h3 class="text-sm font-medium">{{ aiEditConfigId ? t("ai.editConfig") : t("ai.addConfig") }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Config Name Input -->
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.configName") }}</Label>
|
||||
<Input v-model="aiEditConfigName" class="col-span-2 h-8 text-xs" :placeholder="t('ai.configNamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<!-- Provider Selection -->
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.provider") }}</Label>
|
||||
<Select :model-value="aiEditProvider" @update:model-value="(v: any) => aiSelectProvider(v)">
|
||||
@@ -4171,18 +4314,13 @@ onUnmounted(cleanupPreviewEditor);
|
||||
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" />
|
||||
<span>{{ provider.label }}</span>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-1">
|
||||
<span v-if="aiEditProvider === provider.provider" class="rounded px-1 py-0.5 text-[10px] font-medium leading-none" :class="settingsStore.isAiProviderConfigured(provider.provider) ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'">{{
|
||||
t("ai.providerStatusActive")
|
||||
}}</span>
|
||||
<span v-else-if="settingsStore.isAiProviderConfigured(provider.provider)" class="rounded bg-green-500/15 px-1 py-0.5 text-[10px] font-medium leading-none text-green-700 dark:text-green-400">{{ t("ai.providerStatusConfigured") }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Codex CLI MCP Status -->
|
||||
<div v-if="aiIsCodexCli && !isWeb" class="rounded-md border px-3 py-2.5 text-xs" :class="aiCodexMcpNeedsInstall ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
@@ -4216,6 +4354,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication -->
|
||||
<div v-if="!aiIsCodexCli && aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Authentication</Label>
|
||||
<Select v-model="aiEditAuthMethod">
|
||||
@@ -4229,11 +4368,13 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ aiCredentialLabel }}</Label>
|
||||
<PasswordInput v-model="aiEditApiKey" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" :placeholder="aiCredentialPlaceholder" />
|
||||
</div>
|
||||
|
||||
<!-- Endpoint -->
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">Endpoint</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
@@ -4242,6 +4383,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex CLI Path -->
|
||||
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.codexCliPath") }}</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
@@ -4251,6 +4393,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex CLI Env -->
|
||||
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.codexCliEnv") }}</Label>
|
||||
<div class="col-span-2 space-y-2">
|
||||
@@ -4272,48 +4415,63 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.model") }}</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Input v-model="aiEditModel" autocomplete="off" class="h-8 min-w-0 flex-1 text-xs" />
|
||||
<SearchableSelect
|
||||
:model-value="aiEditModel"
|
||||
:options="aiModelOptionIds"
|
||||
:placeholder="t('ai.browseModels')"
|
||||
:search-placeholder="t('ai.searchModels')"
|
||||
:empty-text="aiModelEmptyText"
|
||||
:loading-text="t('ai.loadingModels')"
|
||||
:loading="aiModelLoading"
|
||||
:display-name="displayAiModelName"
|
||||
trigger-class="h-8 min-w-[104px] max-w-[150px] shrink-0 border border-border bg-background px-2 text-xs shadow-none hover:bg-muted/50"
|
||||
content-class="w-72"
|
||||
item-class="h-auto min-h-8 py-1.5"
|
||||
@update:model-value="aiSelectModel"
|
||||
@update:open="onAiModelListOpen"
|
||||
>
|
||||
<template #trigger-label="{ loading }">
|
||||
<span class="truncate">{{ loading ? t("ai.loadingModels") : t("ai.browseModels") }}</span>
|
||||
</template>
|
||||
<template #option-label="{ option, label }">
|
||||
<span class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate">{{ aiModelOptionPresentation(option, label).primary }}</span>
|
||||
<span v-if="aiModelOptionSecondary(option, label)" class="mt-0.5 truncate text-[11px] text-muted-foreground">{{ aiModelOptionSecondary(option, label) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
<Button type="button" size="icon" variant="outline" class="shrink-0" :disabled="aiModelLoading || !aiModelListSupported" :title="t('ai.refreshModels')" :aria-label="t('ai.refreshModels')" @click="aiRefreshModels">
|
||||
<Loader2 v-if="aiModelLoading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCw v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="aiModelError" class="text-xs text-destructive">{{ aiModelError }}</p>
|
||||
<p v-else-if="!aiModelOptionIds.length" class="text-xs text-muted-foreground">
|
||||
{{ aiModelListSupported ? t("ai.modelListHint") : t("ai.modelListUnsupported") }}
|
||||
</p>
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.defaultModel") }}</Label>
|
||||
<div class="col-span-2">
|
||||
<Input v-model="aiEditModel" autocomplete="off" class="h-8 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.modelList") }}</Label>
|
||||
<div class="col-span-2 space-y-2">
|
||||
<div v-for="(m, index) in aiEditModels" :key="index" class="flex items-center gap-2">
|
||||
<Input v-model="m.name" :placeholder="t('ai.modelId')" class="h-8 flex-1 text-xs" />
|
||||
<Input v-model="m.label" :placeholder="t('ai.modelDisplayName')" class="h-8 flex-1 text-xs" />
|
||||
<Button type="button" variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="aiRemoveModel(index)">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" @click="aiAddModel">
|
||||
<Plus class="mr-1 h-3 w-3" />
|
||||
{{ t("ai.add") }}
|
||||
</Button>
|
||||
<Popover v-model:open="aiModelMultiSelectOpen" @update:open="aiOnModelPopoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" :disabled="!aiCanListModels">
|
||||
<Loader2 v-if="aiModelListLoading" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Download v-else class="mr-1 h-3 w-3" />
|
||||
{{ aiModelListLoading ? t("ai.fetching") : t("ai.fetchModelList") }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-56 p-0" align="start">
|
||||
<div class="flex flex-col">
|
||||
<div class="border-b px-3 py-2">
|
||||
<Input v-model="aiModelMultiSelectSearch" :placeholder="t('ai.searchModels')" class="h-8 text-xs" />
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto p-1">
|
||||
<div v-if="aiFilteredFetchedModels.length === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
{{ aiModelListLoading ? t("ai.loadingModels") : aiModelError || t("ai.noModels") }}
|
||||
</div>
|
||||
<button v-for="model in aiFilteredFetchedModels" :key="model.id" type="button" class="flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs hover:bg-muted" @click="aiToggleModel(model)">
|
||||
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="aiIsModelSelected(model.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
|
||||
<Check v-if="aiIsModelSelected(model.id)" class="h-3 w-3" />
|
||||
</div>
|
||||
<span class="flex-1 truncate text-left">{{ model.displayName || model.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p v-if="aiModelError" class="text-xs text-destructive">{{ aiModelError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reasoning Level -->
|
||||
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.reasoningLevel") }}</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
@@ -4331,6 +4489,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Style -->
|
||||
<div v-if="aiSupportsApiStyle" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">API</Label>
|
||||
<div class="col-span-2 flex gap-2">
|
||||
@@ -4340,6 +4499,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enable Thinking -->
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.enableThinking") }}</Label>
|
||||
<div class="col-span-2 flex items-center gap-2">
|
||||
@@ -4358,6 +4518,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Window -->
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.contextWindow") }}</Label>
|
||||
<div class="col-span-2">
|
||||
@@ -4366,6 +4527,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy -->
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.proxy") }}</Label>
|
||||
<label class="col-span-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
@@ -4374,6 +4536,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Proxy URL -->
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.proxyUrl") }}</Label>
|
||||
<Input v-model="aiEditProxyUrl" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" placeholder="socks5://127.0.0.1:7890" :disabled="!aiEditProxyEnabled" />
|
||||
@@ -4755,33 +4918,39 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else-if="activeSettingsTab === 'ai'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCodexValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCodexCli && !aiEditEndpoint?.trim()) || (!aiIsCodexCli && !aiEditModel?.trim())" @click="aiTestConn">
|
||||
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
|
||||
{{ t("connection.test") }}
|
||||
</Button>
|
||||
<span v-if="aiTestResult === 'success'" class="text-xs text-green-500 flex items-center gap-1.5">
|
||||
<span>{{ t("connection.testSuccess") }}</span>
|
||||
<span v-if="aiTestLatency != null" class="text-green-500/70">{{ aiTestLatency }}ms</span>
|
||||
</span>
|
||||
<span v-else-if="aiTestResult === 'error'" class="min-w-0 max-w-[360px] flex items-center gap-1.5 text-xs text-destructive">
|
||||
<span class="select-text truncate" :title="aiTestError">{{ aiTestError }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0 text-destructive/80 hover:text-destructive"
|
||||
:title="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
|
||||
:aria-label="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
|
||||
@click="copyAiTestError"
|
||||
>
|
||||
<CheckCircle2 v-if="aiTestErrorCopied" class="h-3.5 w-3.5" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
<template v-if="aiConfigListMode === 'list'">
|
||||
<div class="flex-1" />
|
||||
<Button variant="outline" @click="closeSettings">{{ t("common.close") }}</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCodexValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCodexCli && !aiEditEndpoint?.trim()) || (!aiIsCodexCli && !aiEditModel?.trim())" @click="aiTestConn">
|
||||
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
|
||||
{{ t("connection.test") }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" @click="closeSettings">{{ t("common.close") }}</Button>
|
||||
<Button :disabled="!aiHasChanges() || !!aiCodexValidationError" @click="aiApplySettings">{{ t("settings.apply") }}</Button>
|
||||
<span v-if="aiTestResult === 'success'" class="text-xs text-green-500 flex items-center gap-1.5">
|
||||
<span>{{ t("connection.testSuccess") }}</span>
|
||||
<span v-if="aiTestLatency != null" class="text-green-500/70">{{ aiTestLatency }}ms</span>
|
||||
</span>
|
||||
<span v-else-if="aiTestResult === 'error'" class="min-w-0 max-w-[360px] flex items-center gap-1.5 text-xs text-destructive">
|
||||
<span class="select-text truncate" :title="aiTestError">{{ aiTestError }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0 text-destructive/80 hover:text-destructive"
|
||||
:title="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
|
||||
:aria-label="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
|
||||
@click="copyAiTestError"
|
||||
>
|
||||
<CheckCircle2 v-if="aiTestErrorCopied" class="h-3.5 w-3.5" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" @click="aiEnterListMode()">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="!aiEditConfigName.trim() || !!aiCodexValidationError" @click="aiSaveConfig">{{ t("settings.apply") }}</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else-if="activeSettingsTab === 'sync'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
|
||||
@@ -4872,6 +5041,9 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- AI Config Delete Confirmation -->
|
||||
<DangerConfirmDialog v-model:open="aiDeleteConfirmOpen" :title="t('ai.deleteConfigTitle')" :message="t('ai.deleteConfigConfirm')" :confirm-label="t('common.delete')" @confirm="aiConfirmDeleteConfig" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ const patterns: [RegExp, string][] = [
|
||||
[/^Custom Java runtime path is empty\. Please choose a Java executable\.$/, "connection.customJavaPathEmpty"],
|
||||
[/^Agent requires Java 21, but DBX started it with an older Java runtime\. Use DBX managed JRE 21 or select a Java 21 executable in Driver Manager\./, "connection.agentJavaTooOld"],
|
||||
[/^JDBC plugin is not installed\. Install the optional JDBC plugin to use this connection\.$/, "connection.jdbcPluginNotInstalled"],
|
||||
[/^ai\.configNameExists:(.+)$/, "ai.configNameExists"],
|
||||
];
|
||||
|
||||
const paramNames: Record<string, string> = {
|
||||
"connection.driverNotInstalled": "driver",
|
||||
"connection.jreNotInstalled": "jre",
|
||||
"ai.configNameExists": "name",
|
||||
};
|
||||
|
||||
export function translateBackendError(t: ComposerTranslation, message: string): string {
|
||||
|
||||
@@ -1148,6 +1148,9 @@ export default {
|
||||
package: "Package",
|
||||
packageBody: "Package Body",
|
||||
noResults: "No results",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
back: "Back",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Search connections, databases, tables, and other objects...",
|
||||
@@ -1285,11 +1288,33 @@ export default {
|
||||
providerStatusConfigured: "Configured",
|
||||
switchProvider: "Switch Provider",
|
||||
model: "Model",
|
||||
defaultModel: "Default Model",
|
||||
browseModels: "Browse models",
|
||||
refreshModels: "Refresh models",
|
||||
loadingModels: "Loading models...",
|
||||
searchModels: "Search models",
|
||||
noModels: "No models loaded",
|
||||
selectModel: "Select a model",
|
||||
noModel: "No model configured",
|
||||
configNameEmpty: "Config name cannot be empty",
|
||||
configNameExists: "Config name '{name}' already exists",
|
||||
|
||||
configList: "AI Config List",
|
||||
addConfig: "Add Config",
|
||||
noAiConfigs: "No AI configs",
|
||||
setDefault: "Set as Default",
|
||||
editConfig: "Edit Config",
|
||||
configName: "Config Name",
|
||||
configNamePlaceholder: "Enter config name",
|
||||
modelList: "Model List",
|
||||
modelId: "Model ID",
|
||||
modelDisplayName: "Display Name",
|
||||
add: "Add",
|
||||
fetching: "Fetching...",
|
||||
fetchModelList: "Fetch Models from API",
|
||||
default: "Default",
|
||||
deleteConfigTitle: "Delete Config",
|
||||
deleteConfigConfirm: "Delete this config?",
|
||||
modelListHint: "Click refresh to load models from the provider.",
|
||||
modelListUnsupported: "This provider does not support fetching a model list.",
|
||||
modelListEndpointRequired: "Enter an endpoint before loading models.",
|
||||
@@ -1424,6 +1449,7 @@ export default {
|
||||
editMessage: "Edit message",
|
||||
editResend: "Resend",
|
||||
editCancel: "Cancel",
|
||||
cannotDeleteActiveConfig: "Cannot delete the currently active config. Switch to another config first.",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Open Connection",
|
||||
|
||||
@@ -1090,6 +1090,9 @@ export default withEnglishFallback({
|
||||
package: "Paquete",
|
||||
packageBody: "Cuerpo del Paquete",
|
||||
noResults: "Sin resultados",
|
||||
edit: "Editar",
|
||||
delete: "Eliminar",
|
||||
back: "Volver",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Buscar conexiones, bases de datos, tablas y otros objetos...",
|
||||
@@ -1227,11 +1230,33 @@ export default withEnglishFallback({
|
||||
providerStatusConfigured: "Configurado",
|
||||
switchProvider: "Cambiar proveedor",
|
||||
model: "Modelo",
|
||||
defaultModel: "Modelo predeterminado",
|
||||
browseModels: "Explorar modelos",
|
||||
refreshModels: "Actualizar modelos",
|
||||
loadingModels: "Cargando modelos...",
|
||||
searchModels: "Buscar modelos",
|
||||
noModels: "No hay modelos cargados",
|
||||
selectModel: "Seleccionar modelo",
|
||||
noModel: "No hay modelo configurado",
|
||||
configNameEmpty: "El nombre de la configuración no puede estar vacío",
|
||||
configNameExists: "El nombre de la configuración '{name}' ya existe",
|
||||
|
||||
configList: "Lista de configuraciones AI",
|
||||
addConfig: "Agregar configuración",
|
||||
noAiConfigs: "No hay configuraciones AI",
|
||||
setDefault: "Establecer como predeterminada",
|
||||
editConfig: "Editar configuración",
|
||||
configName: "Nombre de configuración",
|
||||
configNamePlaceholder: "Ingrese el nombre",
|
||||
modelList: "Lista de modelos",
|
||||
modelId: "ID del modelo",
|
||||
modelDisplayName: "Nombre para mostrar",
|
||||
add: "Agregar",
|
||||
fetching: "Obteniendo...",
|
||||
fetchModelList: "Obtener modelos de la API",
|
||||
default: "Predeterminada",
|
||||
deleteConfigTitle: "Eliminar configuración",
|
||||
deleteConfigConfirm: "¿Eliminar esta configuración?",
|
||||
modelListHint: "Pulsa actualizar para cargar los modelos del proveedor.",
|
||||
modelListUnsupported: "Este proveedor no admite obtener una lista de modelos.",
|
||||
modelListEndpointRequired: "Ingresa un endpoint antes de cargar modelos.",
|
||||
@@ -1366,6 +1391,7 @@ export default withEnglishFallback({
|
||||
editMessage: "Editar mensaje",
|
||||
editResend: "Reenviar",
|
||||
editCancel: "Cancelar",
|
||||
cannotDeleteActiveConfig: "No se puede eliminar la configuración activa actual. Cambie a otra configuración primero.",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Abrir conexión",
|
||||
|
||||
@@ -1088,6 +1088,9 @@ export default withEnglishFallback({
|
||||
package: "Pacchetto",
|
||||
packageBody: "Corpo Pacchetto",
|
||||
noResults: "Nessun risultato",
|
||||
edit: "Modifica",
|
||||
delete: "Elimina",
|
||||
back: "Indietro",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Cerca connessioni, database, tabelle e altri oggetti...",
|
||||
@@ -1225,11 +1228,33 @@ export default withEnglishFallback({
|
||||
providerStatusConfigured: "Configurato",
|
||||
switchProvider: "Cambia provider",
|
||||
model: "Modello",
|
||||
defaultModel: "Modello predefinito",
|
||||
browseModels: "Sfoglia modelli",
|
||||
refreshModels: "Aggiorna modelli",
|
||||
loadingModels: "Caricamento modelli...",
|
||||
searchModels: "Cerca modelli",
|
||||
noModels: "Nessun modello caricato",
|
||||
selectModel: "Seleziona modello",
|
||||
noModel: "Nessun modello configurato",
|
||||
configNameEmpty: "Il nome della configurazione non può essere vuoto",
|
||||
configNameExists: "Il nome della configurazione '{name}' esiste già",
|
||||
|
||||
configList: "Lista configurazioni AI",
|
||||
addConfig: "Aggiungi configurazione",
|
||||
noAiConfigs: "Nessuna configurazione AI",
|
||||
setDefault: "Imposta come predefinita",
|
||||
editConfig: "Modifica configurazione",
|
||||
configName: "Nome configurazione",
|
||||
configNamePlaceholder: "Inserisci nome",
|
||||
modelList: "Lista modelli",
|
||||
modelId: "ID modello",
|
||||
modelDisplayName: "Nome visualizzato",
|
||||
add: "Aggiungi",
|
||||
fetching: "Recupero...",
|
||||
fetchModelList: "Recupera modelli dall'API",
|
||||
default: "Predefinita",
|
||||
deleteConfigTitle: "Elimina configurazione",
|
||||
deleteConfigConfirm: "Eliminare questa configurazione?",
|
||||
modelListHint: "Fai clic su aggiorna per caricare i modelli dal provider.",
|
||||
modelListUnsupported: "Questo provider non supporta il recupero di un elenco di modelli.",
|
||||
modelListEndpointRequired: "Inserisci un endpoint prima di caricare i modelli.",
|
||||
@@ -1364,6 +1389,7 @@ export default withEnglishFallback({
|
||||
editMessage: "Modifica messaggio",
|
||||
editResend: "Invia di nuovo",
|
||||
editCancel: "Annulla",
|
||||
cannotDeleteActiveConfig: "Non è possibile eliminare la configurazione attualmente attiva. Passa a un'altra configurazione prima.",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Apri Connessione",
|
||||
|
||||
@@ -1089,6 +1089,9 @@ export default withEnglishFallback({
|
||||
import: "インポート",
|
||||
remove: "削除",
|
||||
noResults: "結果なし",
|
||||
edit: "編集",
|
||||
delete: "削除",
|
||||
back: "戻る",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "接続、データベース、テーブル、その他のオブジェクトを検索...",
|
||||
@@ -1226,11 +1229,33 @@ export default withEnglishFallback({
|
||||
providerStatusConfigured: "設定済み",
|
||||
switchProvider: "プロバイダーを切替",
|
||||
model: "モデル",
|
||||
defaultModel: "デフォルトモデル",
|
||||
browseModels: "モデルを参照",
|
||||
refreshModels: "モデルを更新",
|
||||
loadingModels: "モデルを読み込み中...",
|
||||
searchModels: "モデルを検索",
|
||||
noModels: "モデルが読み込まれていません",
|
||||
selectModel: "モデルを選択",
|
||||
noModel: "モデルが設定されていません",
|
||||
configNameEmpty: "設定名を入力してください",
|
||||
configNameExists: "設定名 '{name}' は既に存在します",
|
||||
|
||||
configList: "AI設定リスト",
|
||||
addConfig: "設定を追加",
|
||||
noAiConfigs: "AI設定がありません",
|
||||
setDefault: "デフォルトに設定",
|
||||
editConfig: "設定を編集",
|
||||
configName: "設定名",
|
||||
configNamePlaceholder: "設定名を入力",
|
||||
modelList: "モデルリスト",
|
||||
modelId: "モデルID",
|
||||
modelDisplayName: "表示名",
|
||||
add: "追加",
|
||||
fetching: "取得中...",
|
||||
fetchModelList: "APIからモデルを取得",
|
||||
default: "デフォルト",
|
||||
deleteConfigTitle: "設定を削除",
|
||||
deleteConfigConfirm: "この設定を削除しますか?",
|
||||
modelListHint: "更新をクリックしてプロバイダーからモデルを読み込んでください。",
|
||||
modelListUnsupported: "このプロバイダーはモデル一覧の取得をサポートしていません。",
|
||||
modelListEndpointRequired: "モデルを読み込む前にエンドポイントを入力してください。",
|
||||
@@ -1365,6 +1390,7 @@ export default withEnglishFallback({
|
||||
editMessage: "メッセージを編集",
|
||||
editResend: "再送信",
|
||||
editCancel: "キャンセル",
|
||||
cannotDeleteActiveConfig: "現在アクティブな設定は削除できません。先に他の設定に切り替えてください。",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "接続を開く",
|
||||
|
||||
@@ -1090,6 +1090,9 @@ export default withEnglishFallback({
|
||||
package: "Pacote",
|
||||
packageBody: "Corpo do Pacote",
|
||||
noResults: "Sem resultados",
|
||||
edit: "Editar",
|
||||
delete: "Excluir",
|
||||
back: "Voltar",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Pesquisar conexões, bancos de dados, tabelas e outros objetos...",
|
||||
@@ -1227,11 +1230,33 @@ export default withEnglishFallback({
|
||||
providerStatusConfigured: "Configurado",
|
||||
switchProvider: "Trocar provedor",
|
||||
model: "Modelo",
|
||||
defaultModel: "Modelo padrão",
|
||||
browseModels: "Explorar modelos",
|
||||
refreshModels: "Atualizar modelos",
|
||||
loadingModels: "Carregando modelos...",
|
||||
searchModels: "Pesquisar modelos",
|
||||
noModels: "Nenhum modelo carregado",
|
||||
selectModel: "Selecionar modelo",
|
||||
noModel: "Nenhum modelo configurado",
|
||||
configNameEmpty: "O nome da configuração não pode estar vazio",
|
||||
configNameExists: "O nome da configuração '{name}' já existe",
|
||||
|
||||
configList: "Lista de configurações AI",
|
||||
addConfig: "Adicionar configuração",
|
||||
noAiConfigs: "Nenhuma configuração AI",
|
||||
setDefault: "Definir como padrão",
|
||||
editConfig: "Editar configuração",
|
||||
configName: "Nome da configuração",
|
||||
configNamePlaceholder: "Digite o nome",
|
||||
modelList: "Lista de modelos",
|
||||
modelId: "ID do modelo",
|
||||
modelDisplayName: "Nome de exibição",
|
||||
add: "Adicionar",
|
||||
fetching: "Buscando...",
|
||||
fetchModelList: "Buscar modelos da API",
|
||||
default: "Padrão",
|
||||
deleteConfigTitle: "Excluir configuração",
|
||||
deleteConfigConfirm: "Excluir esta configuração?",
|
||||
modelListHint: "Clique em atualizar para carregar os modelos do provedor.",
|
||||
modelListUnsupported: "Este provedor não oferece suporte à obtenção de uma lista de modelos.",
|
||||
modelListEndpointRequired: "Informe um endpoint antes de carregar os modelos.",
|
||||
@@ -1366,6 +1391,7 @@ export default withEnglishFallback({
|
||||
editMessage: "Editar mensagem",
|
||||
editResend: "Reenviar",
|
||||
editCancel: "Cancelar",
|
||||
cannotDeleteActiveConfig: "Não é possível excluir a configuração ativa atual. Mude para outra configuração primeiro.",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Abrir Conexão",
|
||||
|
||||
@@ -1148,6 +1148,9 @@ export default withEnglishFallback({
|
||||
package: "包",
|
||||
packageBody: "包体",
|
||||
noResults: "无结果",
|
||||
edit: "编辑",
|
||||
delete: "删除",
|
||||
back: "返回",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "搜索连接、数据库、表和其他对象...",
|
||||
@@ -1285,11 +1288,33 @@ export default withEnglishFallback({
|
||||
providerStatusConfigured: "已配置",
|
||||
switchProvider: "切换服务商",
|
||||
model: "模型",
|
||||
defaultModel: "默认模型",
|
||||
browseModels: "浏览模型",
|
||||
refreshModels: "刷新模型",
|
||||
loadingModels: "加载模型中...",
|
||||
searchModels: "搜索模型",
|
||||
noModels: "暂无模型",
|
||||
selectModel: "选择模型",
|
||||
noModel: "未配置模型",
|
||||
configNameEmpty: "配置名称不能为空",
|
||||
configNameExists: "配置名称 '{name}' 已存在",
|
||||
|
||||
configList: "AI 配置列表",
|
||||
addConfig: "新增配置",
|
||||
noAiConfigs: "暂无 AI 配置",
|
||||
setDefault: "设为默认",
|
||||
editConfig: "编辑配置",
|
||||
configName: "配置名称",
|
||||
configNamePlaceholder: "请输入配置名称",
|
||||
modelList: "模型列表",
|
||||
modelId: "模型 ID",
|
||||
modelDisplayName: "显示名",
|
||||
add: "添加",
|
||||
fetching: "获取中...",
|
||||
fetchModelList: "从 API 获取模型列表",
|
||||
default: "默认",
|
||||
deleteConfigTitle: "删除配置",
|
||||
deleteConfigConfirm: "确认删除该配置?",
|
||||
modelListHint: "点击刷新从服务端拉取模型列表。",
|
||||
modelListUnsupported: "当前提供商不支持自动获取模型列表。",
|
||||
modelListEndpointRequired: "填写 Endpoint 后再获取模型列表。",
|
||||
@@ -1424,6 +1449,7 @@ export default withEnglishFallback({
|
||||
editMessage: "编辑消息",
|
||||
editResend: "重新发送",
|
||||
editCancel: "取消",
|
||||
cannotDeleteActiveConfig: "无法删除当前活跃的配置,请先切换到其他配置",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "打开连接",
|
||||
|
||||
@@ -1090,6 +1090,9 @@ export default withEnglishFallback({
|
||||
package: "套件",
|
||||
packageBody: "套件本體",
|
||||
noResults: "無結果",
|
||||
edit: "編輯",
|
||||
delete: "刪除",
|
||||
back: "返回",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "搜尋連線、資料庫、資料表和其他物件……",
|
||||
@@ -1227,11 +1230,33 @@ export default withEnglishFallback({
|
||||
providerStatusConfigured: "已設定",
|
||||
switchProvider: "切換服務商",
|
||||
model: "模型",
|
||||
defaultModel: "預設模型",
|
||||
browseModels: "瀏覽模型",
|
||||
refreshModels: "重新整理模型",
|
||||
loadingModels: "載入模型中……",
|
||||
searchModels: "搜尋模型",
|
||||
noModels: "尚未載入模型",
|
||||
selectModel: "選擇模型",
|
||||
noModel: "尚未設定模型",
|
||||
configNameEmpty: "設定名稱不能為空",
|
||||
configNameExists: "設定名稱 '{name}' 已存在",
|
||||
|
||||
configList: "AI 配置列表",
|
||||
addConfig: "新增配置",
|
||||
noAiConfigs: "暫無 AI 配置",
|
||||
setDefault: "設為預設",
|
||||
editConfig: "編輯配置",
|
||||
configName: "配置名稱",
|
||||
configNamePlaceholder: "請輸入配置名稱",
|
||||
modelList: "模型列表",
|
||||
modelId: "模型 ID",
|
||||
modelDisplayName: "顯示名稱",
|
||||
add: "新增",
|
||||
fetching: "獲取中...",
|
||||
fetchModelList: "從 API 獲取模型列表",
|
||||
default: "預設",
|
||||
deleteConfigTitle: "刪除配置",
|
||||
deleteConfigConfirm: "確認刪除該配置?",
|
||||
modelListHint: "點選重新整理以從供應商載入模型。",
|
||||
modelListUnsupported: "此供應商不支援取得模型清單。",
|
||||
modelListEndpointRequired: "填寫 Endpoint 後再取得模型清單。",
|
||||
@@ -1366,6 +1391,7 @@ export default withEnglishFallback({
|
||||
editMessage: "編輯訊息",
|
||||
editResend: "重新傳送",
|
||||
editCancel: "取消",
|
||||
cannotDeleteActiveConfig: "無法刪除當前活躍的配置,請先切換到其他配置",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "開啟連線",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAiModelOption, shouldShowAiModelSecondaryLabel } from "@/lib/ai/aiModelPresentation";
|
||||
|
||||
describe("shouldShowAiModelSecondaryLabel", () => {
|
||||
it("hides secondary text when display name only changes case", () => {
|
||||
expect(shouldShowAiModelSecondaryLabel("GPT-5.5", "gpt-5.5")).toBe(false);
|
||||
});
|
||||
|
||||
it("hides secondary text when display name only changes separators", () => {
|
||||
expect(shouldShowAiModelSecondaryLabel("GPT-5.4-Mini", "gpt-5.4-mini")).toBe(false);
|
||||
});
|
||||
|
||||
it("hides secondary text for exact matches", () => {
|
||||
expect(shouldShowAiModelSecondaryLabel("gpt-5.3-codex", "gpt-5.3-codex")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps secondary text when provider display name is meaningfully different", () => {
|
||||
expect(shouldShowAiModelSecondaryLabel("Claude Sonnet 4", "claude-sonnet-4-20250514")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAiModelOption", () => {
|
||||
it("returns a single-line Codex-style model option", () => {
|
||||
expect(formatAiModelOption("GPT-5.5", "gpt-5.5")).toEqual({ primary: "GPT-5.5", secondary: undefined });
|
||||
});
|
||||
|
||||
it("returns model id as secondary text only when useful", () => {
|
||||
expect(formatAiModelOption("Claude Sonnet 4", "claude-sonnet-4-20250514")).toEqual({
|
||||
primary: "Claude Sonnet 4",
|
||||
secondary: "claude-sonnet-4-20250514",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AiConfig, AiConfigItem } from "@/types/ai";
|
||||
|
||||
export type { AiConfigItem };
|
||||
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function getConfigKey(config: AiConfig): string {
|
||||
return `${config.provider}|${config.apiKey}|${config.endpoint}|${config.model}`;
|
||||
}
|
||||
|
||||
export function aiConfigToItem(config: AiConfig, id: string, name: string): AiConfigItem {
|
||||
return {
|
||||
...config,
|
||||
id,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
export type ConfigNameValidationResult = "empty" | "duplicate" | "valid";
|
||||
|
||||
export function validateConfigName(name: string, configs: AiConfigItem[], excludeId?: string): ConfigNameValidationResult {
|
||||
if (!name.trim()) return "empty";
|
||||
if (configs.some((c) => c.name.toLowerCase() === name.toLowerCase() && c.id !== excludeId)) {
|
||||
return "duplicate";
|
||||
}
|
||||
return "valid";
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface AiModelPresentation {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
}
|
||||
|
||||
function compactModelName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, "");
|
||||
}
|
||||
|
||||
export function shouldShowAiModelSecondaryLabel(label: string, modelId: string): boolean {
|
||||
const normalizedLabel = compactModelName(label);
|
||||
const normalizedId = compactModelName(modelId);
|
||||
if (!normalizedLabel || !normalizedId || normalizedLabel === normalizedId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatAiModelOption(label: string, modelId: string): AiModelPresentation {
|
||||
return {
|
||||
primary: label || modelId,
|
||||
secondary: shouldShowAiModelSecondaryLabel(label, modelId) ? modelId : undefined,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import type * as TauriModule from "@/lib/backend/tauri";
|
||||
import { appendDebugLog } from "@/lib/backend/debugLog";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { AiConfigItem } from "@/types/ai";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy backend resolution (avoids top-level await)
|
||||
@@ -238,6 +239,11 @@ export const aiTestConnection = forward("aiTestConnection");
|
||||
export const aiListModels = forward("aiListModels");
|
||||
export const saveAiConfig = forward("saveAiConfig");
|
||||
export const loadAiConfig = forward("loadAiConfig");
|
||||
export const saveAiConfigs = forward("saveAiConfigs");
|
||||
export const loadAiConfigs = forward("loadAiConfigs");
|
||||
export const setDefaultAiConfig = forward("setDefaultAiConfig");
|
||||
export const saveAiConfigItem = forward("saveAiConfigItem");
|
||||
export const deleteAiConfig = forward("deleteAiConfig");
|
||||
export const saveAiProviderConfig = forward("saveAiProviderConfig");
|
||||
export const loadAiProviderConfigs = forward("loadAiProviderConfigs");
|
||||
export const loadDesktopSettings = forward("loadDesktopSettings");
|
||||
@@ -487,6 +493,8 @@ export const loadSidebarLayout = forward("loadSidebarLayout");
|
||||
// Re-export all types from tauri.ts (shared between both backends)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type { AiConfigItem };
|
||||
|
||||
export type {
|
||||
AppSupportInfo,
|
||||
AiMessage,
|
||||
|
||||
@@ -1123,6 +1123,26 @@ export async function loadAiConfig(): Promise<AiConfig | null> {
|
||||
return get("/api/ai/config");
|
||||
}
|
||||
|
||||
export async function saveAiConfigs(configs: import("@/types/ai").AiConfigItem[]): Promise<void> {
|
||||
return post("/api/ai/configs", { configs });
|
||||
}
|
||||
|
||||
export async function loadAiConfigs(): Promise<import("@/types/ai").AiConfigItem[]> {
|
||||
return get("/api/ai/configs");
|
||||
}
|
||||
|
||||
export async function setDefaultAiConfig(configId: string): Promise<void> {
|
||||
return post("/api/ai/default-config", { configId });
|
||||
}
|
||||
|
||||
export async function saveAiConfigItem(config: import("@/types/ai").AiConfigItem): Promise<void> {
|
||||
return post("/api/ai/config-item", { config });
|
||||
}
|
||||
|
||||
export async function deleteAiConfig(configId: string): Promise<void> {
|
||||
return del(`/api/ai/config/${configId}`);
|
||||
}
|
||||
|
||||
export async function loadDesktopSettings(): Promise<DesktopSettings> {
|
||||
try {
|
||||
const raw = safeLocalStorageGet(DESKTOP_SETTINGS_STORAGE_KEY);
|
||||
|
||||
@@ -42,6 +42,7 @@ import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
|
||||
import type { AiConfigItem } from "@/types/ai";
|
||||
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
||||
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
|
||||
import type {
|
||||
@@ -413,6 +414,26 @@ export async function aiCancelStream(sessionId: string): Promise<boolean> {
|
||||
return invoke("ai_cancel_stream", { sessionId });
|
||||
}
|
||||
|
||||
export async function saveAiConfigs(configs: AiConfigItem[]): Promise<void> {
|
||||
return invoke("save_ai_configs", { configs });
|
||||
}
|
||||
|
||||
export async function loadAiConfigs(): Promise<AiConfigItem[]> {
|
||||
return invoke("load_ai_configs");
|
||||
}
|
||||
|
||||
export async function setDefaultAiConfig(configId: string): Promise<void> {
|
||||
return invoke("set_default_ai_config", { configId });
|
||||
}
|
||||
|
||||
export async function saveAiConfigItem(config: AiConfigItem): Promise<void> {
|
||||
return invoke("save_ai_config_item", { config });
|
||||
}
|
||||
|
||||
export async function deleteAiConfig(configId: string): Promise<void> {
|
||||
return invoke("delete_ai_config", { configId });
|
||||
}
|
||||
|
||||
export async function loadAiConfig(): Promise<AiConfig | null> {
|
||||
return invoke("load_ai_config");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { generateId, getConfigKey, aiConfigToItem } from "@/lib/ai/aiConfigList";
|
||||
import { normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
|
||||
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
@@ -14,35 +15,9 @@ import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates";
|
||||
import { DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
|
||||
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
|
||||
import type { AiProvider, AiApiStyle, AiAuthMethod, AiReasoningLevel, AiConfig, AiTestConnectionResult, AiConfigItem } from "@/types/ai";
|
||||
|
||||
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom";
|
||||
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
|
||||
export type AiAuthMethod = "api-key" | "bearer";
|
||||
export type AiReasoningLevel = "default" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
export interface AiConfig {
|
||||
provider: AiProvider;
|
||||
apiKey: string;
|
||||
authMethod: AiAuthMethod;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
apiStyle: AiApiStyle;
|
||||
proxyEnabled?: boolean;
|
||||
proxyUrl?: string;
|
||||
enableThinking?: boolean;
|
||||
reasoningLevel?: AiReasoningLevel;
|
||||
contextWindow?: number;
|
||||
codexCliPath?: string | null;
|
||||
codexCliEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AiTestConnectionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latencyMs?: number;
|
||||
modelUsed: string;
|
||||
errorCategory?: string;
|
||||
}
|
||||
export type { AiProvider, AiApiStyle, AiAuthMethod, AiReasoningLevel, AiConfig, AiTestConnectionResult, AiConfigItem };
|
||||
|
||||
export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
@@ -853,9 +828,9 @@ function saveEditorSettings(settings: EditorSettings) {
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
const settingsPageActive = ref(false);
|
||||
const aiConfig = ref<AiConfig>(normalizeAiConfig({ provider: "claude" }));
|
||||
const activeModel = ref<{ configId: string; modelId: string } | null>(null);
|
||||
const isAiConfigLoaded = ref(false);
|
||||
const aiProviderConfigs = ref<Partial<Record<AiProvider, AiConfig>>>({});
|
||||
const aiConfigs = ref<AiConfigItem[]>([]);
|
||||
const desktopSettings = ref<DesktopSettings>({ ...DEFAULT_DESKTOP_SETTINGS });
|
||||
const isDesktopSettingsLoaded = ref(false);
|
||||
const isEditorSettingsLoaded = ref(false);
|
||||
@@ -910,71 +885,118 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function initAiConfig() {
|
||||
async function initAiConfigs(): Promise<void> {
|
||||
if (isAiConfigLoaded.value) return;
|
||||
const legacy = localStorage.getItem("dbx-ai-config");
|
||||
const [savedActive, savedProviderConfigs] = await Promise.all([api.loadAiConfig().catch(() => null), api.loadAiProviderConfigs().catch(() => null) as Promise<Partial<Record<AiProvider, AiConfig>> | null>]);
|
||||
|
||||
if (savedActive) {
|
||||
aiConfig.value = normalizeAiConfig(savedActive);
|
||||
} else if (legacy) {
|
||||
aiConfig.value = normalizeAiConfig(JSON.parse(legacy));
|
||||
await api.saveAiConfig(aiConfig.value).catch(() => {});
|
||||
localStorage.removeItem("dbx-ai-config");
|
||||
// 尝试加载新格式
|
||||
const newConfigs = await api.loadAiConfigs();
|
||||
|
||||
if (newConfigs.length > 0) {
|
||||
aiConfigs.value = newConfigs;
|
||||
} else {
|
||||
// 迁移旧格式
|
||||
await migrateToMultiConfig();
|
||||
}
|
||||
|
||||
if (savedProviderConfigs) {
|
||||
aiProviderConfigs.value = savedProviderConfigs;
|
||||
// 同步活跃状态到默认配置
|
||||
const defaultConfig = aiConfigs.value.find((c) => c.isDefault) || aiConfigs.value[0];
|
||||
if (defaultConfig) {
|
||||
activeModel.value = { configId: defaultConfig.id, modelId: defaultConfig.model };
|
||||
}
|
||||
|
||||
// Ensure active config is reflected in provider map (overwrite if exists, to guarantee consistency)
|
||||
const activeProvider = aiConfig.value.provider;
|
||||
aiProviderConfigs.value[activeProvider] = { ...aiConfig.value };
|
||||
|
||||
isAiConfigLoaded.value = true;
|
||||
}
|
||||
|
||||
function updateAiConfig(config: Partial<AiConfig>) {
|
||||
const previousProvider = aiConfig.value.provider;
|
||||
const targetProvider = config.provider;
|
||||
const switchingProvider = !!targetProvider && targetProvider !== previousProvider;
|
||||
|
||||
if (switchingProvider) {
|
||||
// Save current provider's config before switching
|
||||
aiProviderConfigs.value[previousProvider] = { ...aiConfig.value };
|
||||
api.saveAiProviderConfig(previousProvider, aiConfig.value).catch(() => {});
|
||||
|
||||
// Restore saved config for target provider, or use preset defaults
|
||||
const savedConfig = aiProviderConfigs.value[targetProvider];
|
||||
if (savedConfig) {
|
||||
aiConfig.value = normalizeAiConfig({ ...savedConfig, provider: targetProvider });
|
||||
async function reloadAiConfigs(): Promise<void> {
|
||||
isAiConfigLoaded.value = false;
|
||||
await initAiConfigs();
|
||||
// If the active config was deleted, fall back to the default
|
||||
if (activeModel.value && !aiConfigs.value.find((c) => c.id === activeModel.value!.configId)) {
|
||||
if (aiConfigs.value.length > 0) {
|
||||
activeModel.value = { configId: aiConfigs.value[0].id, modelId: aiConfigs.value[0].model };
|
||||
} else {
|
||||
aiConfig.value = normalizeAiConfig({ provider: targetProvider });
|
||||
activeModel.value = null;
|
||||
}
|
||||
// Don't merge caller fields when switching — caller passes only { provider }
|
||||
} else {
|
||||
// Not switching provider — apply partial update
|
||||
Object.assign(aiConfig.value, config);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateToMultiConfig(): Promise<void> {
|
||||
const oldActiveConfig = await api.loadAiConfig().catch(() => null);
|
||||
const oldProviderConfigs = await api.loadAiProviderConfigs().catch(() => null);
|
||||
|
||||
if (!oldActiveConfig && (!oldProviderConfigs || Object.keys(oldProviderConfigs).length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
aiProviderConfigs.value[aiConfig.value.provider] = { ...aiConfig.value };
|
||||
api.saveAiConfig(aiConfig.value).catch(() => {});
|
||||
api.saveAiProviderConfig(aiConfig.value.provider, aiConfig.value).catch(() => {});
|
||||
const newConfigs: AiConfigItem[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
if (oldActiveConfig) {
|
||||
const item = aiConfigToItem(normalizeAiConfig(oldActiveConfig), generateId(), oldActiveConfig.provider);
|
||||
item.isDefault = true;
|
||||
newConfigs.push(item);
|
||||
seenKeys.add(getConfigKey(oldActiveConfig));
|
||||
}
|
||||
|
||||
if (oldProviderConfigs) {
|
||||
for (const [provider, config] of Object.entries(oldProviderConfigs)) {
|
||||
const key = getConfigKey(config);
|
||||
if (!seenKeys.has(key)) {
|
||||
const item = aiConfigToItem(normalizeAiConfig(config), generateId(), provider);
|
||||
item.isDefault = false;
|
||||
newConfigs.push(item);
|
||||
seenKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newConfigs.length > 0) {
|
||||
await api.saveAiConfigs(newConfigs);
|
||||
aiConfigs.value = newConfigs;
|
||||
}
|
||||
}
|
||||
|
||||
function isConfigured(): boolean {
|
||||
const preset = AI_PROVIDER_PRESETS[aiConfig.value.provider];
|
||||
if (aiConfig.value.provider === "codex-cli") return true;
|
||||
return !!aiConfig.value.endpoint && !!aiConfig.value.model && (!preset.requiresApiKey || !!aiConfig.value.apiKey);
|
||||
async function createAiConfig(config: AiConfigItem): Promise<void> {
|
||||
await api.saveAiConfigItem(config);
|
||||
aiConfigs.value.push(config);
|
||||
if (aiConfigs.value.length === 1) {
|
||||
activeModel.value = { configId: config.id, modelId: config.model };
|
||||
}
|
||||
}
|
||||
|
||||
function isAiProviderConfigured(provider: AiProvider): boolean {
|
||||
const config = aiProviderConfigs.value[provider];
|
||||
async function updateAiConfigItem(id: string, config: Partial<AiConfigItem>): Promise<void> {
|
||||
const index = aiConfigs.value.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
const updated = { ...aiConfigs.value[index], ...config };
|
||||
await api.saveAiConfigItem(updated);
|
||||
aiConfigs.value[index] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAiConfig(id: string): Promise<void> {
|
||||
await api.deleteAiConfig(id);
|
||||
aiConfigs.value = aiConfigs.value.filter((c) => c.id !== id);
|
||||
}
|
||||
|
||||
async function setDefaultAiConfig(id: string): Promise<void> {
|
||||
await api.setDefaultAiConfig(id);
|
||||
aiConfigs.value.forEach((c) => {
|
||||
c.isDefault = c.id === id;
|
||||
});
|
||||
}
|
||||
|
||||
function updateActiveModel(model: { configId: string; modelId: string }) {
|
||||
activeModel.value = model;
|
||||
}
|
||||
|
||||
const isConfigured = computed((): boolean => {
|
||||
if (!activeModel.value) return false;
|
||||
const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId);
|
||||
if (!config) return false;
|
||||
if (provider === "codex-cli") return true;
|
||||
const preset = AI_PROVIDER_PRESETS[provider];
|
||||
return !!config.endpoint?.trim() && !!config.model?.trim() && (!preset.requiresApiKey || !!config.apiKey?.trim());
|
||||
}
|
||||
const preset = AI_PROVIDER_PRESETS[config.provider];
|
||||
if (config.provider === "codex-cli") return true;
|
||||
return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
|
||||
});
|
||||
|
||||
function updateEditorSettings(partial: Partial<EditorSettings>) {
|
||||
if (partial.fontFamily !== undefined) editorSettings.value.fontFamily = normalizeFontFamily(partial.fontFamily, DEFAULT_EDITOR_SETTINGS.fontFamily);
|
||||
@@ -1110,13 +1132,18 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
|
||||
return {
|
||||
settingsPageActive,
|
||||
aiConfig,
|
||||
activeModel,
|
||||
isAiConfigLoaded,
|
||||
aiProviderConfigs,
|
||||
initAiConfig,
|
||||
updateAiConfig,
|
||||
aiConfigs,
|
||||
initAiConfigs,
|
||||
reloadAiConfigs,
|
||||
migrateToMultiConfig,
|
||||
createAiConfig,
|
||||
updateAiConfigItem,
|
||||
deleteAiConfig,
|
||||
setDefaultAiConfig,
|
||||
updateActiveModel,
|
||||
isConfigured,
|
||||
isAiProviderConfigured,
|
||||
isEditorSettingsLoaded,
|
||||
editorSettings,
|
||||
desktopSettings,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom";
|
||||
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
|
||||
export type AiAuthMethod = "api-key" | "bearer";
|
||||
export type AiReasoningLevel = "default" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
export interface AiConfig {
|
||||
provider: AiProvider;
|
||||
apiKey: string;
|
||||
authMethod: AiAuthMethod;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
models?: Array<{ name: string; label?: string }>;
|
||||
apiStyle: AiApiStyle;
|
||||
proxyEnabled?: boolean;
|
||||
proxyUrl?: string;
|
||||
enableThinking?: boolean;
|
||||
reasoningLevel?: AiReasoningLevel;
|
||||
contextWindow?: number;
|
||||
codexCliPath?: string | null;
|
||||
codexCliEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AiTestConnectionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latencyMs?: number;
|
||||
modelUsed: string;
|
||||
errorCategory?: string;
|
||||
}
|
||||
|
||||
export interface AiConfigItem extends AiConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream cancel registry
|
||||
@@ -63,6 +64,22 @@ pub enum AiProvider {
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl AiProvider {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AiProvider::Claude => "claude",
|
||||
AiProvider::Openai => "openai",
|
||||
AiProvider::Gemini => "gemini",
|
||||
AiProvider::Deepseek => "deepseek",
|
||||
AiProvider::Qwen => "qwen",
|
||||
AiProvider::Ollama => "ollama",
|
||||
AiProvider::OpenaiCompatible => "openai-compatible",
|
||||
AiProvider::CodexCli => "codex-cli",
|
||||
AiProvider::Custom => "custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AiApiStyle {
|
||||
@@ -104,6 +121,31 @@ impl AiReasoningLevel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiConfigItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub is_default: bool,
|
||||
#[serde(flatten)]
|
||||
pub config: AiConfig,
|
||||
}
|
||||
|
||||
impl AiConfigItem {
|
||||
pub fn new_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiModelListItem {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiConfig {
|
||||
@@ -117,6 +159,8 @@ pub struct AiConfig {
|
||||
#[serde(default)]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub models: Vec<AiModelListItem>,
|
||||
#[serde(default)]
|
||||
pub api_style: AiApiStyle,
|
||||
#[serde(default)]
|
||||
pub proxy_enabled: bool,
|
||||
@@ -2573,6 +2617,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
|
||||
model: "gpt-4o".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: true,
|
||||
proxy_url: "not a proxy url".to_string(),
|
||||
@@ -2596,6 +2641,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
|
||||
model: "gpt-4o".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: true,
|
||||
proxy_url: "127.0.0.1:7890".to_string(),
|
||||
@@ -2617,6 +2663,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "http://127.0.0.1:3456/v1".to_string(),
|
||||
model: "gpt-4o".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: true,
|
||||
proxy_url: "not a proxy url".to_string(),
|
||||
@@ -2638,6 +2685,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::ApiKey,
|
||||
endpoint: "https://generativelanguage.googleapis.com".to_string(),
|
||||
model: "gemini-1.5-pro".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -2663,6 +2711,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "http://localhost:11434/v1".to_string(),
|
||||
model: "llama3.1".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -2685,6 +2734,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
|
||||
model: String::new(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -2702,6 +2752,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::ApiKey,
|
||||
endpoint: "https://api.anthropic.com/v1/messages".to_string(),
|
||||
model: String::new(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -2722,6 +2773,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::ApiKey,
|
||||
endpoint: "https://gateway.example.com/anthropic/v1".to_string(),
|
||||
model: "claude-sonnet-4-20250514".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::AnthropicMessages,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -2766,6 +2818,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.example.com".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -2822,6 +2875,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::ApiKey,
|
||||
endpoint: "https://api.anthropic.com/v1/messages".to_string(),
|
||||
model: "claude-sonnet-4-20250514".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -3110,6 +3164,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
|
||||
model: "gpt-5.5".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -3175,6 +3230,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.moonshot.cn/v1".to_string(),
|
||||
model: "kimi-k2.5".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -3205,6 +3261,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
models: vec![],
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -3241,6 +3298,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://example.com/v1".to_string(),
|
||||
model: "qwen3".to_string(),
|
||||
models: vec![],
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
@@ -3271,6 +3329,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "http://localhost:11434/v1".to_string(),
|
||||
model: "deepseek-r1:14b".to_string(),
|
||||
models: vec![],
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
|
||||
@@ -605,6 +605,7 @@ mod tests {
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: String::new(),
|
||||
model: model.to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
|
||||
@@ -9,7 +9,7 @@ use reqwest::{header, Client, Method, StatusCode, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::ai::AiConfig;
|
||||
use crate::ai::AiConfigItem;
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY,
|
||||
MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
|
||||
@@ -119,7 +119,12 @@ pub struct EncryptedSecretsBlob {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SensitiveSyncPayload {
|
||||
pub connection_secrets: Vec<ConnectionSecretSnapshot>,
|
||||
pub ai_config: Option<AiConfig>,
|
||||
// None = legacy snapshot (fall through to ai_config migration),
|
||||
// Some(vec) = explicit state (empty vec means all configs were deleted)
|
||||
pub ai_configs: Option<Vec<AiConfigItem>>,
|
||||
/// Legacy field, used only for deserializing old data; not serialized
|
||||
#[serde(default, skip_serializing)]
|
||||
pub ai_config: Option<crate::ai::AiConfig>,
|
||||
/// Full tunnel profiles including their secrets.
|
||||
#[serde(default)]
|
||||
pub tunnel_profiles: Option<Vec<TransportLayerConfig>>,
|
||||
@@ -664,7 +669,8 @@ async fn build_sensitive_payload(
|
||||
|
||||
Ok(SensitiveSyncPayload {
|
||||
connection_secrets,
|
||||
ai_config: storage.load_ai_config().await?,
|
||||
ai_configs: Some(storage.load_ai_configs().await.unwrap_or_default()),
|
||||
ai_config: None,
|
||||
tunnel_profiles: Some(tunnel_profiles.to_vec()),
|
||||
})
|
||||
}
|
||||
@@ -784,8 +790,19 @@ async fn apply_sensitive_payload(storage: &Storage, payload: &SensitiveSyncPaylo
|
||||
}
|
||||
storage.set_secret(&secret.connection_id, &secret.key, &secret.secret).await?;
|
||||
}
|
||||
if let Some(ai_config) = &payload.ai_config {
|
||||
storage.save_ai_config(ai_config).await?;
|
||||
if let Some(configs) = &payload.ai_configs {
|
||||
// New format: save directly (empty = all configs were deleted)
|
||||
storage.save_ai_configs(configs).await?;
|
||||
} else if let Some(old_config) = &payload.ai_config {
|
||||
// Legacy format: migrate then save
|
||||
let provider_name = old_config.provider.as_str().to_string();
|
||||
let item = AiConfigItem {
|
||||
id: AiConfigItem::new_id(),
|
||||
name: provider_name,
|
||||
is_default: true,
|
||||
config: old_config.clone(),
|
||||
};
|
||||
storage.save_ai_config_item(&item).await?;
|
||||
}
|
||||
if let Some(profiles) = &payload.tunnel_profiles {
|
||||
storage.save_tunnel_profiles(profiles).await?;
|
||||
@@ -1024,18 +1041,43 @@ fn parent_collection_paths(remote_path: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload,
|
||||
encrypt_sensitive_payload, forget_webdav_sync_secrets_passphrase, gitee_snippet_payload,
|
||||
normalized_remote_path, parent_collection_paths, resolve_webdav_sync_secrets_passphrase,
|
||||
apply_sensitive_payload, apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets,
|
||||
decrypt_sensitive_payload, encrypt_sensitive_payload, forget_webdav_sync_secrets_passphrase,
|
||||
gitee_snippet_payload, normalized_remote_path, parent_collection_paths, resolve_webdav_sync_secrets_passphrase,
|
||||
save_webdav_sync_secrets_preference, scrub_connection_secrets, snippet_file_content, snippet_response_id,
|
||||
webdav_sync_secrets_status, ApplySnapshotOptions, ConnectionSecretSnapshot, SensitiveSyncPayload,
|
||||
};
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem};
|
||||
use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY;
|
||||
use crate::models::connection::{
|
||||
default_redis_key_separator, ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig,
|
||||
};
|
||||
use crate::storage::Storage;
|
||||
|
||||
fn make_test_config(name: &str, is_default: bool) -> AiConfigItem {
|
||||
AiConfigItem {
|
||||
id: format!("cfg-{name}"),
|
||||
name: name.to_string(),
|
||||
is_default,
|
||||
config: AiConfig {
|
||||
provider: crate::ai::AiProvider::Openai,
|
||||
api_key: String::new(),
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://api.openai.com/v1".to_string(),
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
models: vec![],
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: crate::ai::AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_db_path(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("dbx-cloud-sync-{name}-{}.db", uuid::Uuid::new_v4()))
|
||||
}
|
||||
@@ -1310,6 +1352,7 @@ mod tests {
|
||||
secret: "hop-secret".to_string(),
|
||||
},
|
||||
],
|
||||
ai_configs: None,
|
||||
ai_config: None,
|
||||
};
|
||||
let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap();
|
||||
@@ -1328,6 +1371,7 @@ mod tests {
|
||||
key: "password".to_string(),
|
||||
secret: "secret".to_string(),
|
||||
}],
|
||||
ai_configs: None,
|
||||
ai_config: None,
|
||||
};
|
||||
let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap();
|
||||
@@ -1478,4 +1522,59 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(target.load_tunnel_profiles().await.unwrap(), vec![profile]);
|
||||
}
|
||||
|
||||
// ---- AI configs sync tests ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn sensitive_payload_ai_configs_none_falls_through_to_legacy() {
|
||||
let storage = Storage::open(&temp_db_path("ai-cfg-none")).await.unwrap();
|
||||
|
||||
// No ai_configs in payload — fall through to ai_config (legacy) branch
|
||||
let payload = SensitiveSyncPayload {
|
||||
connection_secrets: vec![],
|
||||
ai_configs: None,
|
||||
ai_config: None,
|
||||
tunnel_profiles: None,
|
||||
};
|
||||
apply_sensitive_payload(&storage, &payload).await.unwrap();
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert!(loaded.is_empty(), "None → no configs written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sensitive_payload_ai_configs_empty_clears_table() {
|
||||
let storage = Storage::open(&temp_db_path("ai-cfg-empty")).await.unwrap();
|
||||
|
||||
// Pre-populate with a config
|
||||
let cfg = make_test_config("to-be-cleared", true);
|
||||
storage.save_ai_config_item(&cfg).await.unwrap();
|
||||
|
||||
// Some([]) — explicit clear
|
||||
let payload = SensitiveSyncPayload {
|
||||
connection_secrets: vec![],
|
||||
ai_configs: Some(vec![]),
|
||||
ai_config: None,
|
||||
tunnel_profiles: None,
|
||||
};
|
||||
apply_sensitive_payload(&storage, &payload).await.unwrap();
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert!(loaded.is_empty(), "Some([]) → table cleared");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sensitive_payload_ai_configs_some_saves_configs() {
|
||||
let storage = Storage::open(&temp_db_path("ai-cfg-some")).await.unwrap();
|
||||
|
||||
let cfg = make_test_config("synced", true);
|
||||
let payload = SensitiveSyncPayload {
|
||||
connection_secrets: vec![],
|
||||
ai_configs: Some(vec![cfg]),
|
||||
ai_config: None,
|
||||
tunnel_profiles: None,
|
||||
};
|
||||
apply_sensitive_payload(&storage, &payload).await.unwrap();
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].name, "synced");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use rusqlite::{params, params_from_iter, Connection, DatabaseName, OpenFlags, Op
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai::{AiChatMessage, AiConfig, AiConversation, AiProvider};
|
||||
use crate::ai::{AiChatMessage, AiConfig, AiConfigItem, AiConversation, AiProvider};
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX,
|
||||
MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY,
|
||||
@@ -332,6 +332,14 @@ const SCHEMA_STATEMENTS: &[&str] = &[
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS ai_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
models TEXT NOT NULL DEFAULT '[]',
|
||||
config_json TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
];
|
||||
|
||||
impl Storage {
|
||||
@@ -351,6 +359,7 @@ impl Storage {
|
||||
ensure_history_columns_sync(conn)?;
|
||||
ensure_saved_sql_columns_sync(conn)?;
|
||||
ensure_tab_runtime_cache_columns_sync(conn)?;
|
||||
ensure_ai_configs_columns_sync(conn)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -484,6 +493,24 @@ fn unix_timestamp_millis() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(i64::MAX as u128) as i64
|
||||
}
|
||||
|
||||
fn ensure_ai_configs_columns_sync(conn: &Connection) -> Result<(), String> {
|
||||
const COLUMNS: &[(&str, &str)] = &[
|
||||
("model", "TEXT NOT NULL DEFAULT ''"),
|
||||
("models", "TEXT NOT NULL DEFAULT '[]'"),
|
||||
("is_default", "INTEGER NOT NULL DEFAULT 0"),
|
||||
];
|
||||
|
||||
ensure_table_columns(conn, "ai_configs", COLUMNS)?;
|
||||
|
||||
// Create partial unique index (if not exists)
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_configs_default ON ai_configs(is_default) WHERE is_default = 1",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn ensure_table_columns(conn: &Connection, table_name: &str, columns: &[(&str, &str)]) -> Result<(), String> {
|
||||
let mut stmt =
|
||||
conn.prepare(&format!("SELECT name FROM pragma_table_info('{table_name}')")).map_err(|e| e.to_string())?;
|
||||
@@ -815,6 +842,136 @@ impl Storage {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_ai_configs(&self, configs: &[AiConfigItem]) -> Result<(), String> {
|
||||
let configs = configs.to_vec();
|
||||
self.with_conn(move |conn| {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM ai_configs", []).map_err(|e| e.to_string())?;
|
||||
for config in &configs {
|
||||
let json = serde_json::to_string(&config.config).map_err(|e| e.to_string())?;
|
||||
let models_json = serde_json::to_string(&config.config.models).map_err(|e| e.to_string())?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO ai_configs (id, name, model, models, config_json, is_default) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![config.id, config.name, config.config.model, models_json, json, config.is_default as i32],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Clear old single-config tables — migration is complete, avoids re-migration on empty ai_configs
|
||||
tx.execute("DELETE FROM ai_config", []).map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM ai_provider_configs", []).map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_ai_configs(&self) -> Result<Vec<AiConfigItem>, String> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, model, models, config_json, is_default FROM ai_configs")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, bool>(5)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut configs = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, model_col, models_json_col, json, is_default_col) = row.map_err(|e| e.to_string())?;
|
||||
match serde_json::from_str::<AiConfig>(&json) {
|
||||
Ok(mut config) => {
|
||||
// 优先使用列值,如果列值为空则从 config_json 回退读取
|
||||
if model_col.is_empty() {
|
||||
// config.model 已经从 json 解析出来了
|
||||
} else {
|
||||
config.model = model_col;
|
||||
}
|
||||
if models_json_col.is_empty() || models_json_col == "[]" {
|
||||
// config.models 已经从 json 解析出来了
|
||||
} else {
|
||||
config.models = serde_json::from_str(&models_json_col).unwrap_or_default();
|
||||
}
|
||||
let is_default = is_default_col;
|
||||
configs.push(AiConfigItem { id, name, is_default, config });
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to deserialize AI config item '{}': {}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(configs)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_default_ai_config(&self, config_id: &str) -> Result<(), String> {
|
||||
let config_id = config_id.to_string();
|
||||
self.with_conn(move |conn| {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
tx.execute("UPDATE ai_configs SET is_default = 0 WHERE is_default = 1", []).map_err(|e| e.to_string())?;
|
||||
tx.execute("UPDATE ai_configs SET is_default = 1 WHERE id = ?1", params![config_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_ai_config_item(&self, config: &AiConfigItem) -> Result<(), String> {
|
||||
let config = config.clone();
|
||||
self.with_conn(move |conn| {
|
||||
let json = serde_json::to_string(&config.config).map_err(|e| e.to_string())?;
|
||||
let models_json = serde_json::to_string(&config.config.models).map_err(|e| e.to_string())?;
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
|
||||
// 如果设该配置为默认,先清除其他默认,避免与 idx_ai_configs_default 冲突
|
||||
if config.is_default {
|
||||
tx.execute(
|
||||
"UPDATE ai_configs SET is_default = 0 WHERE is_default = 1 AND id != ?1",
|
||||
params![config.id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO ai_configs (id, name, model, models, config_json, is_default)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, model = excluded.model,
|
||||
models = excluded.models, config_json = excluded.config_json, is_default = excluded.is_default",
|
||||
params![config.id, config.name, config.config.model, models_json, json, config.is_default as i32],
|
||||
)
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
// SQLite UNIQUE constraint error contains the table and column name
|
||||
if msg.contains("UNIQUE constraint failed") && msg.contains("ai_configs.name") {
|
||||
format!("ai.configNameExists:{}", config.name)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
})?;
|
||||
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_ai_config(&self, config_id: &str) -> Result<(), String> {
|
||||
let config_id = config_id.to_string();
|
||||
self.with_conn(move |conn| {
|
||||
conn.execute("DELETE FROM ai_configs WHERE id = ?1", params![config_id]).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel profiles — shared transport-layer configurations managed in
|
||||
@@ -3592,4 +3749,194 @@ mod tests {
|
||||
assert_eq!(loaded.open_count, 1);
|
||||
assert_eq!(loaded.sql, "SELECT 1;");
|
||||
}
|
||||
|
||||
// ---- AI Config tests ----
|
||||
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem, AiProvider, AiReasoningLevel};
|
||||
|
||||
fn make_ai_config(name: &str, is_default: bool) -> AiConfigItem {
|
||||
AiConfigItem {
|
||||
id: format!("cfg-{name}"),
|
||||
name: name.to_string(),
|
||||
is_default,
|
||||
config: AiConfig {
|
||||
provider: AiProvider::Openai,
|
||||
api_key: "sk-test".to_string(),
|
||||
auth_method: AiAuthMethod::ApiKey,
|
||||
endpoint: "https://api.openai.com/v1".to_string(),
|
||||
model: "gpt-4o".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: std::collections::HashMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_save_load_roundtrip() {
|
||||
let db = temp_db_path("ai-roundtrip");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let cfg = make_ai_config("test-config", true);
|
||||
storage.save_ai_config_item(&cfg).await.unwrap();
|
||||
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, "cfg-test-config");
|
||||
assert_eq!(loaded[0].name, "test-config");
|
||||
assert!(loaded[0].is_default);
|
||||
assert_eq!(loaded[0].config.model, "gpt-4o");
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_only_one_default() {
|
||||
let db = temp_db_path("ai-one-default");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let cfg1 = make_ai_config("config-a", true);
|
||||
let cfg2 = make_ai_config("config-b", true);
|
||||
storage.save_ai_config_item(&cfg1).await.unwrap();
|
||||
|
||||
// Second default config should succeed and cascade-clear the first
|
||||
storage.save_ai_config_item(&cfg2).await.unwrap();
|
||||
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
let defaults: Vec<_> = loaded.iter().filter(|c| c.is_default).collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0].id, "cfg-config-b");
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_update_existing_to_default() {
|
||||
let db = temp_db_path("ai-update-default");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let cfg1 = make_ai_config("config-a", true);
|
||||
let cfg2 = make_ai_config("config-b", false);
|
||||
storage.save_ai_config_item(&cfg1).await.unwrap();
|
||||
storage.save_ai_config_item(&cfg2).await.unwrap();
|
||||
assert_eq!(storage.load_ai_configs().await.unwrap().iter().filter(|c| c.is_default).count(), 1);
|
||||
|
||||
// Update cfg-b to be default via save_ai_config_item — should succeed and clear cfg-a
|
||||
let cfg2 = make_ai_config("config-b", true);
|
||||
storage.save_ai_config_item(&cfg2).await.unwrap();
|
||||
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
let defaults: Vec<_> = loaded.iter().filter(|c| c.is_default).collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0].id, "cfg-config-b");
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_duplicate_name_error() {
|
||||
let db = temp_db_path("ai-dup-name");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let cfg1 = make_ai_config("same-name", false);
|
||||
storage.save_ai_config_item(&cfg1).await.unwrap();
|
||||
|
||||
// Different id, same name → should fail with name conflict
|
||||
let mut cfg2 = make_ai_config("same-name", false);
|
||||
cfg2.id = "cfg-other".to_string();
|
||||
let err = storage.save_ai_config_item(&cfg2).await.unwrap_err();
|
||||
assert!(err.contains("ai.configNameExists"), "Expected name conflict error, got: {err}");
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_set_default_switches() {
|
||||
let db = temp_db_path("ai-set-default");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let cfg1 = make_ai_config("first", true);
|
||||
let cfg2 = make_ai_config("second", false);
|
||||
storage.save_ai_config_item(&cfg1).await.unwrap();
|
||||
storage.save_ai_config_item(&cfg2).await.unwrap();
|
||||
|
||||
// Switch default to second
|
||||
storage.set_default_ai_config("cfg-second").await.unwrap();
|
||||
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
let first = loaded.iter().find(|c| c.id == "cfg-first").unwrap();
|
||||
let second = loaded.iter().find(|c| c.id == "cfg-second").unwrap();
|
||||
assert!(!first.is_default);
|
||||
assert!(second.is_default);
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_delete_default_no_cascade() {
|
||||
let db = temp_db_path("ai-delete-default");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let cfg1 = make_ai_config("default-one", true);
|
||||
let cfg2 = make_ai_config("other", false);
|
||||
storage.save_ai_config_item(&cfg1).await.unwrap();
|
||||
storage.save_ai_config_item(&cfg2).await.unwrap();
|
||||
|
||||
// Delete the default config
|
||||
storage.delete_ai_config("cfg-default-one").await.unwrap();
|
||||
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
// Remaining config should NOT be auto-promoted to default
|
||||
assert!(!loaded[0].is_default);
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_save_configs_batch() {
|
||||
let db = temp_db_path("ai-batch");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let configs =
|
||||
vec![make_ai_config("batch-a", true), make_ai_config("batch-b", false), make_ai_config("batch-c", false)];
|
||||
storage.save_ai_configs(&configs).await.unwrap();
|
||||
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert_eq!(loaded.len(), 3);
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_config_save_configs_clears_old_tables() {
|
||||
let db = temp_db_path("ai-clear-old");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
// Pre-populate old tables as if migration hasn't run yet
|
||||
storage.save_ai_config(&make_ai_config("legacy-active", false).config).await.unwrap();
|
||||
storage.save_ai_provider_config("openai", &make_ai_config("legacy-openai", false).config).await.unwrap();
|
||||
|
||||
// save_ai_configs should clear old tables
|
||||
let configs = vec![make_ai_config("new-a", true)];
|
||||
storage.save_ai_configs(&configs).await.unwrap();
|
||||
|
||||
// New table has the saved config
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].name, "new-a");
|
||||
|
||||
// Old tables are cleared — prevents re-migration on restart
|
||||
assert!(storage.load_ai_config().await.unwrap().is_none(), "ai_config should be deleted");
|
||||
let old_providers = storage.load_ai_provider_configs().await.unwrap();
|
||||
assert!(old_providers.is_empty(), "ai_provider_configs should be deleted");
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +517,10 @@ async fn main() {
|
||||
.route("/ai/config", post(routes::ai::save_ai_config).get(routes::ai::load_ai_config))
|
||||
.route("/ai/provider-config", post(routes::ai::save_ai_provider_config))
|
||||
.route("/ai/provider-configs", get(routes::ai::load_ai_provider_configs))
|
||||
.route("/ai/configs", post(routes::ai::save_ai_configs).get(routes::ai::load_ai_configs))
|
||||
.route("/ai/default-config", post(routes::ai::set_default_ai_config))
|
||||
.route("/ai/config-item", post(routes::ai::save_ai_config_item))
|
||||
.route("/ai/config/{config_id}", delete(routes::ai::delete_ai_config))
|
||||
.route("/ai/conversation", post(routes::ai::save_ai_conversation))
|
||||
.route("/ai/conversations", get(routes::ai::load_ai_conversations))
|
||||
.route("/ai/conversation/{id}", delete(routes::ai::delete_ai_conversation))
|
||||
|
||||
@@ -10,7 +10,8 @@ use serde::Deserialize;
|
||||
use dbx_core::agent_events::AgentEvent;
|
||||
use dbx_core::agent_loop::{run_agent_loop, AgentLoopContext};
|
||||
use dbx_core::ai::{
|
||||
AiCompletionRequest, AiConfig, AiConversation, AiModelInfo, AiProvider, AiStreamChunk, AiTestConnectionResult,
|
||||
AiCompletionRequest, AiConfig, AiConfigItem, AiConversation, AiModelInfo, AiProvider, AiStreamChunk,
|
||||
AiTestConnectionResult,
|
||||
};
|
||||
use dbx_core::models::connection::DatabaseType;
|
||||
|
||||
@@ -140,6 +141,69 @@ pub async fn load_ai_provider_configs(
|
||||
Ok(Json(configs))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveAiConfigsRequest {
|
||||
pub configs: Vec<AiConfigItem>,
|
||||
}
|
||||
|
||||
pub async fn save_ai_configs(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveAiConfigsRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
for item in &body.configs {
|
||||
reject_web_unsupported_ai_provider(&item.config)?;
|
||||
}
|
||||
state.app.storage.save_ai_configs(&body.configs).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_ai_configs(State(state): State<Arc<WebState>>) -> Result<Json<Vec<AiConfigItem>>, AppError> {
|
||||
let configs = state.app.storage.load_ai_configs().await.map_err(AppError)?;
|
||||
Ok(Json(configs))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDefaultAiConfigRequest {
|
||||
pub config_id: String,
|
||||
}
|
||||
|
||||
pub async fn set_default_ai_config(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SetDefaultAiConfigRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.set_default_ai_config(&body.config_id).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveAiConfigItemRequest {
|
||||
pub config: AiConfigItem,
|
||||
}
|
||||
|
||||
pub async fn save_ai_config_item(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveAiConfigItemRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
reject_web_unsupported_ai_provider(&body.config.config)?;
|
||||
state.app.storage.save_ai_config_item(&body.config).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_ai_config(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(config_id): Path<String>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.delete_ai_config(&config_id).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -326,3 +390,51 @@ pub async fn ai_agent_stream(
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::reject_web_unsupported_ai_provider;
|
||||
use dbx_core::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel};
|
||||
|
||||
fn make_config(provider: AiProvider) -> AiConfig {
|
||||
AiConfig {
|
||||
provider,
|
||||
api_key: String::new(),
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: "https://example.com".to_string(),
|
||||
model: "test".to_string(),
|
||||
models: vec![],
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_codex_cli_single() {
|
||||
let config = make_config(AiProvider::CodexCli);
|
||||
assert!(reject_web_unsupported_ai_provider(&config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_other_providers_single() {
|
||||
for provider in &[
|
||||
AiProvider::Claude,
|
||||
AiProvider::Openai,
|
||||
AiProvider::OpenaiCompatible,
|
||||
AiProvider::Custom,
|
||||
AiProvider::Gemini,
|
||||
AiProvider::Deepseek,
|
||||
AiProvider::Qwen,
|
||||
AiProvider::Ollama,
|
||||
] {
|
||||
let config = make_config(provider.clone());
|
||||
assert!(reject_web_unsupported_ai_provider(&config).is_ok(), "provider {:?} should be allowed", provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
saveAiConfigItem: vi.fn<[unknown]>().mockResolvedValue(undefined),
|
||||
deleteAiConfig: vi.fn<[string]>().mockResolvedValue(undefined),
|
||||
setDefaultAiConfig: vi.fn<[string]>().mockResolvedValue(undefined),
|
||||
loadAiConfigs: vi.fn<[]>().mockResolvedValue([]),
|
||||
saveAiConfigs: vi.fn<[unknown[]]>().mockResolvedValue(undefined),
|
||||
loadEditorSettings: vi.fn<[]>().mockResolvedValue(null),
|
||||
saveEditorSettings: vi.fn<[unknown]>().mockResolvedValue(undefined),
|
||||
loadDesktopSettings: vi.fn<[]>().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
|
||||
import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts";
|
||||
|
||||
test("createAiConfig rejects -> state unchanged", async () => {
|
||||
setActivePinia(createPinia());
|
||||
apiMock.saveAiConfigItem.mockRejectedValueOnce(new Error("db error"));
|
||||
|
||||
const store = useSettingsStore();
|
||||
await assert.rejects(
|
||||
() => store.createAiConfig({ id: "c1", name: "test", isDefault: false } as any),
|
||||
/db error/,
|
||||
);
|
||||
assert.equal(store.aiConfigs.length, 0);
|
||||
});
|
||||
|
||||
test("deleteAiConfig rejects -> state unchanged", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "c1", name: "a", isDefault: true } as any);
|
||||
store.aiConfigs.push({ id: "c2", name: "b", isDefault: false } as any);
|
||||
|
||||
apiMock.deleteAiConfig.mockRejectedValueOnce(new Error("db error"));
|
||||
await assert.rejects(() => store.deleteAiConfig("c1"), /db error/);
|
||||
assert.equal(store.aiConfigs.length, 2);
|
||||
});
|
||||
|
||||
test("updateAiConfigItem rejects -> state unchanged", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "c1", name: "old", config: {} } as any);
|
||||
|
||||
apiMock.saveAiConfigItem.mockRejectedValueOnce(new Error("db error"));
|
||||
await assert.rejects(() => store.updateAiConfigItem("c1", { name: "new" }), /db error/);
|
||||
assert.equal(store.aiConfigs[0].name, "old");
|
||||
});
|
||||
|
||||
test("setDefaultAiConfig rejects -> isDefault unchanged", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "c1", name: "a", isDefault: true } as any);
|
||||
store.aiConfigs.push({ id: "c2", name: "b", isDefault: false } as any);
|
||||
|
||||
apiMock.setDefaultAiConfig.mockRejectedValueOnce(new Error("db error"));
|
||||
await assert.rejects(() => store.setDefaultAiConfig("c2"), /db error/);
|
||||
assert.ok(store.aiConfigs.find((c: any) => c.id === "c1")!.isDefault);
|
||||
assert.ok(!store.aiConfigs.find((c: any) => c.id === "c2")!.isDefault);
|
||||
});
|
||||
|
||||
test("createAiConfig succeeds -> state reflects new config", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
await store.createAiConfig({ id: "c1", name: "t", model: "m", config: {} } as any);
|
||||
assert.equal(store.aiConfigs.length, 1);
|
||||
assert.equal(store.aiConfigs[0].id, "c1");
|
||||
});
|
||||
|
||||
test("updateAiConfigItem succeeds -> state updated", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "c1", name: "old", config: {} } as any);
|
||||
await store.updateAiConfigItem("c1", { name: "new" });
|
||||
assert.equal(store.aiConfigs[0].name, "new");
|
||||
});
|
||||
|
||||
test("deleteAiConfig succeeds -> state reflects deletion", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "c1", name: "a" } as any);
|
||||
await store.deleteAiConfig("c1");
|
||||
assert.equal(store.aiConfigs.length, 0);
|
||||
});
|
||||
|
||||
test("setDefaultAiConfig succeeds -> isDefault flipped", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "c1", name: "a", isDefault: true } as any);
|
||||
store.aiConfigs.push({ id: "c2", name: "b", isDefault: false } as any);
|
||||
|
||||
await store.setDefaultAiConfig("c2");
|
||||
assert.ok(!store.aiConfigs.find((c: any) => c.id === "c1")!.isDefault);
|
||||
assert.ok(store.aiConfigs.find((c: any) => c.id === "c2")!.isDefault);
|
||||
});
|
||||
|
||||
test("reloadAiConfigs resets and reloads from API", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "old", name: "stale", isDefault: true } as any);
|
||||
|
||||
apiMock.loadAiConfigs.mockResolvedValueOnce([
|
||||
{ id: "fresh", name: "fresh", isDefault: true } as any,
|
||||
]);
|
||||
|
||||
await store.reloadAiConfigs();
|
||||
assert.equal(store.aiConfigs.length, 1);
|
||||
assert.equal(store.aiConfigs[0].id, "fresh");
|
||||
assert.equal(store.activeModel?.configId, "fresh");
|
||||
});
|
||||
|
||||
test("reloadAiConfigs falls back when active config was deleted", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useSettingsStore();
|
||||
|
||||
// Simulate post-sync: activeModel points to a config no longer in ai_configs
|
||||
store.aiConfigs.push({ id: "remaining", name: "r", model: "m", isDefault: true } as any);
|
||||
store.activeModel = { configId: "deleted", modelId: "gone" };
|
||||
|
||||
apiMock.loadAiConfigs.mockResolvedValueOnce([
|
||||
{ id: "remaining", name: "r", model: "m", isDefault: true } as any,
|
||||
]);
|
||||
|
||||
await store.reloadAiConfigs();
|
||||
assert.equal(store.aiConfigs.length, 1);
|
||||
// activeModel should fall back to the first (and only) remaining config
|
||||
assert.equal(store.activeModel?.configId, "remaining");
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::connection::AppState;
|
||||
use dbx_core::ai::AiConfigItem;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_ai_configs(state: State<'_, Arc<AppState>>, configs: Vec<AiConfigItem>) -> Result<(), String> {
|
||||
state.storage.save_ai_configs(&configs).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_ai_configs(state: State<'_, Arc<AppState>>) -> Result<Vec<AiConfigItem>, String> {
|
||||
state.storage.load_ai_configs().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_default_ai_config(state: State<'_, Arc<AppState>>, config_id: String) -> Result<(), String> {
|
||||
state.storage.set_default_ai_config(&config_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_ai_config_item(state: State<'_, Arc<AppState>>, config: AiConfigItem) -> Result<(), String> {
|
||||
state.storage.save_ai_config_item(&config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_ai_config(state: State<'_, Arc<AppState>>, config_id: String) -> Result<(), String> {
|
||||
state.storage.delete_ai_config(&config_id).await
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod agents;
|
||||
pub mod ai;
|
||||
pub mod ai_multi_config;
|
||||
pub mod app_settings;
|
||||
pub mod cloud_sync;
|
||||
pub mod connection;
|
||||
|
||||
@@ -1031,6 +1031,11 @@ pub fn run() {
|
||||
commands::ai::save_ai_conversation,
|
||||
commands::ai::load_ai_conversations,
|
||||
commands::ai::delete_ai_conversation,
|
||||
commands::ai_multi_config::save_ai_configs,
|
||||
commands::ai_multi_config::load_ai_configs,
|
||||
commands::ai_multi_config::set_default_ai_config,
|
||||
commands::ai_multi_config::save_ai_config_item,
|
||||
commands::ai_multi_config::delete_ai_config,
|
||||
commands::app_settings::load_desktop_settings,
|
||||
commands::app_settings::save_desktop_settings,
|
||||
commands::app_settings::complete_app_close,
|
||||
|
||||
Reference in New Issue
Block a user