feat(ai): add provider presets

This commit is contained in:
t8y2
2026-05-17 14:59:18 +08:00
parent 7188465161
commit 52033f0846
7 changed files with 525 additions and 65 deletions
+61 -18
View File
@@ -13,6 +13,7 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import {
useSettingsStore,
AI_PROVIDER_PRESETS,
EDITOR_THEMES,
FONT_FAMILIES,
DEFAULT_EDITOR_SETTINGS,
@@ -30,6 +31,7 @@ import {
normalizeShortcutSettings,
type ShortcutActionId,
} from "@/lib/shortcutRegistry";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
const { t } = useI18n();
const settingsStore = useSettingsStore();
@@ -259,11 +261,8 @@ async function changePassword() {
}
// ---------- AI Settings ----------
const aiProviderDefaults: Record<AiProvider, { endpoint: string; model: string }> = {
claude: { endpoint: "https://api.anthropic.com/v1/messages", model: "claude-sonnet-4-20250514" },
openai: { endpoint: "https://api.openai.com/v1/chat/completions", model: "gpt-4o" },
custom: { endpoint: "", model: "" },
};
const aiProviderOptions = Object.values(AI_PROVIDER_PRESETS);
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
const aiEditProvider = ref<AiProvider>(settingsStore.aiConfig.provider);
const aiEditApiKey = ref(settingsStore.aiConfig.apiKey);
@@ -279,6 +278,13 @@ const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions")
const aiTesting = ref(false);
const aiTestResult = ref<"" | "success" | "error">("");
const aiTestError = ref("");
const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey);
const aiSupportsApiStyle = computed(
() =>
aiEditProvider.value === "openai" ||
aiEditProvider.value === "openai-compatible" ||
aiEditProvider.value === "custom",
);
function syncAiEditState() {
aiEditProvider.value = settingsStore.aiConfig.provider;
@@ -295,8 +301,10 @@ function syncAiEditState() {
function aiSelectProvider(provider: AiProvider) {
aiEditProvider.value = provider;
aiEditEndpoint.value = aiProviderDefaults[provider].endpoint;
aiEditModel.value = aiProviderDefaults[provider].model;
aiEditEndpoint.value = AI_PROVIDER_PRESETS[provider].endpoint;
aiEditModel.value = AI_PROVIDER_PRESETS[provider].model;
aiEditApiStyle.value = AI_PROVIDER_PRESETS[provider].apiStyle;
if (!AI_PROVIDER_PRESETS[provider].requiresApiKey) aiEditApiKey.value = "";
}
function aiHasChanges(): boolean {
@@ -339,6 +347,7 @@ async function aiTestConn() {
apiStyle: aiEditApiStyle.value,
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
});
aiTestResult.value = "success";
} catch (e: any) {
@@ -478,12 +487,13 @@ watch(
<SelectTrigger>
<SelectValue :placeholder="t('settings.selectFont')" />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" class="min-w-[210px]">
<SelectItem
v-for="font in FONT_FAMILIES"
:key="font.value"
:value="font.value"
:style="{ fontFamily: font.value }"
class="whitespace-nowrap"
>
{{ font.label }}
</SelectItem>
@@ -775,18 +785,46 @@ watch(
<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)">
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectTrigger class="col-span-2 h-8 text-xs">
<SelectValue>
<span class="flex items-center gap-2">
<AiProviderLogo
:provider="selectedAiProviderPreset.provider"
:label="selectedAiProviderPreset.label"
:icon-slug="selectedAiProviderPreset.iconSlug"
/>
<span>{{ selectedAiProviderPreset.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem
v-for="provider in aiProviderOptions"
:key="provider.provider"
:value="provider.provider"
>
<span class="flex items-center gap-2">
<AiProviderLogo
:provider="provider.provider"
:label="provider.label"
:icon-slug="provider.iconSlug"
/>
<span>{{ provider.label }}</span>
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API Key</Label>
<Input v-model="aiEditApiKey" type="password" autocomplete="off" class="col-span-2 h-8 text-xs" />
<Input
v-model="aiEditApiKey"
type="password"
autocomplete="off"
class="col-span-2 h-8 text-xs"
:placeholder="aiRequiresApiKey ? '' : 'Optional'"
/>
</div>
<div class="grid grid-cols-3 items-center gap-3">
@@ -804,7 +842,7 @@ watch(
<Input v-model="aiEditModel" autocomplete="off" class="col-span-2 h-8 text-xs" />
</div>
<div v-if="aiEditProvider !== 'claude'" class="grid grid-cols-3 items-center gap-3">
<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">
<Button
@@ -834,7 +872,7 @@ watch(
v-model="aiEditEnableThinking"
type="checkbox"
class="h-4 w-4 shrink-0 accent-primary"
:disabled="!aiCompletionsMode"
:disabled="!aiCompletionsMode || aiEditProvider === 'gemini'"
/>
{{ aiEditEnableThinking ? t("ai.enableThinkingOn") : t("ai.enableThinkingOff") }}
</label>
@@ -869,12 +907,17 @@ watch(
</div>
</div>
<DialogFooter class="mt-auto flex items-center gap-2">
<div class="flex-1 flex items-center gap-2">
<DialogFooter class="mt-auto border-t-0 bg-transparent gap-3 sm:gap-3">
<div class="flex flex-1 items-center gap-2">
<Button
size="sm"
variant="outline"
:disabled="aiTesting || !aiEditApiKey?.trim() || !aiEditEndpoint?.trim() || !aiEditModel?.trim()"
:disabled="
aiTesting ||
(aiRequiresApiKey && !aiEditApiKey?.trim()) ||
!aiEditEndpoint?.trim() ||
!aiEditModel?.trim()
"
@click="aiTestConn"
>
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Settings2 } from "lucide-vue-next";
import type { AiProvider } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
const props = defineProps<{
provider: AiProvider;
label: string;
iconSlug?: string;
}>();
const failed = ref(false);
const { isDark } = useTheme();
watch(
() => props.iconSlug,
() => {
failed.value = false;
},
);
const usesWhiteDarkIcon = computed(
() =>
props.provider === "claude" ||
props.provider === "ollama" ||
props.provider === "openai" ||
props.provider === "openai-compatible",
);
const localIconUrl = computed(() =>
props.provider === "openai" || props.provider === "openai-compatible" ? "/icons/ai/openai.svg" : "",
);
const iconUrl = computed(
() =>
localIconUrl.value ||
(props.iconSlug
? `https://cdn.simpleicons.org/${props.iconSlug}${isDark.value && usesWhiteDarkIcon.value ? "/f8fafc" : ""}`
: ""),
);
const fallbackText = computed(() => {
if (props.provider === "openai-compatible") return "OC";
return props.label.slice(0, 1).toUpperCase();
});
</script>
<template>
<span class="flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-sm">
<Settings2 v-if="provider === 'custom'" class="h-4 w-4 text-muted-foreground" />
<img
v-else-if="iconUrl && !failed"
:src="iconUrl"
:alt="label"
class="h-4 w-4 object-contain"
:class="{ 'dark:invert': localIconUrl }"
loading="lazy"
@error="failed = true"
/>
<span v-else class="flex h-4 w-4 items-center justify-center rounded-sm bg-muted text-[8px] font-semibold">
{{ fallbackText }}
</span>
</span>
</template>
+3 -8
View File
@@ -11,8 +11,7 @@ defineOptions({
});
const props = withDefaults(defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(), {
position: "item-aligned",
align: "center",
position: "popper",
});
const emits = defineEmits<SelectContentEmits>();
@@ -31,7 +30,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
cn(
'text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
'w-fit min-w-[var(--reka-select-trigger-width)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
@@ -39,11 +38,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<SelectScrollUpButton />
<SelectViewport
:data-position="position"
:class="
cn(
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
)
"
:class="cn('data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full')"
>
<slot />
</SelectViewport>
+102 -8
View File
@@ -5,7 +5,15 @@ import { normalizeColumnFormatter, type ColumnFormatterConfig } from "@/lib/colu
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/shortcutRegistry";
import type { SidebarActivation } from "@/lib/treeNodeClick";
export type AiProvider = "claude" | "openai" | "custom";
export type AiProvider =
| "claude"
| "openai"
| "gemini"
| "deepseek"
| "qwen"
| "ollama"
| "openai-compatible"
| "custom";
export type AiApiStyle = "completions" | "responses";
export interface AiConfig {
@@ -19,22 +27,107 @@ export interface AiConfig {
enableThinking?: boolean;
}
const defaultConfigs: Record<AiProvider, Omit<AiConfig, "apiKey">> = {
export interface AiProviderPreset extends Omit<AiConfig, "apiKey"> {
label: string;
iconSlug?: string;
requiresApiKey: boolean;
}
export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
claude: {
label: "Claude",
iconSlug: "anthropic",
provider: "claude",
endpoint: "https://api.anthropic.com/v1/messages",
model: "claude-sonnet-4-20250514",
apiStyle: "completions",
requiresApiKey: true,
},
openai: {
label: "OpenAI",
iconSlug: "openai",
provider: "openai",
endpoint: "https://api.openai.com/v1/chat/completions",
model: "gpt-4o",
model: "gpt-4o-mini",
apiStyle: "completions",
requiresApiKey: true,
},
gemini: {
label: "Gemini",
iconSlug: "googlegemini",
provider: "gemini",
endpoint: "https://generativelanguage.googleapis.com",
model: "gemini-1.5-pro",
apiStyle: "completions",
requiresApiKey: true,
},
deepseek: {
label: "DeepSeek",
iconSlug: "deepseek",
provider: "deepseek",
endpoint: "https://api.deepseek.com/v1",
model: "deepseek-v4-flash",
apiStyle: "completions",
requiresApiKey: true,
},
qwen: {
label: "Qwen",
iconSlug: "alibabacloud",
provider: "qwen",
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen-plus",
apiStyle: "completions",
requiresApiKey: true,
},
ollama: {
label: "Ollama",
iconSlug: "ollama",
provider: "ollama",
endpoint: "http://localhost:11434/v1",
model: "llama3.1",
apiStyle: "completions",
requiresApiKey: false,
},
"openai-compatible": {
label: "OpenAI Compatible",
iconSlug: "openai",
provider: "openai-compatible",
endpoint: "",
model: "",
apiStyle: "completions",
requiresApiKey: true,
},
custom: {
label: "Custom",
provider: "custom",
endpoint: "",
model: "",
apiStyle: "completions",
requiresApiKey: true,
},
custom: { provider: "custom", endpoint: "", model: "", apiStyle: "completions" },
};
const defaultConfigs: Record<AiProvider, Omit<AiConfig, "apiKey">> = Object.fromEntries(
Object.entries(AI_PROVIDER_PRESETS).map(([provider, preset]) => {
const { label: _label, iconSlug: _iconSlug, requiresApiKey: _requiresApiKey, ...config } = preset;
return [provider, config];
}),
) as Record<AiProvider, Omit<AiConfig, "apiKey">>;
export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined): AiConfig {
const provider = config?.provider && config.provider in AI_PROVIDER_PRESETS ? config.provider : "claude";
return {
...defaultConfigs[provider],
apiKey: config?.apiKey ?? "",
...config,
provider,
apiStyle: config?.apiStyle ?? defaultConfigs[provider].apiStyle,
proxyEnabled: !!config?.proxyEnabled,
proxyUrl: config?.proxyUrl ?? "",
enableThinking: config?.enableThinking ?? true,
};
}
export type EditorTheme =
| "one-dark"
| "vscode-dark"
@@ -167,7 +260,7 @@ function saveEditorSettings(settings: EditorSettings) {
}
export const useSettingsStore = defineStore("settings", () => {
const aiConfig = ref<AiConfig>({ ...defaultConfigs.claude, apiKey: "", apiStyle: "completions" });
const aiConfig = ref<AiConfig>(normalizeAiConfig({ provider: "claude" }));
const isAiConfigLoaded = ref(false);
const editorSettings = ref<EditorSettings>(loadEditorSettings());
@@ -177,9 +270,9 @@ export const useSettingsStore = defineStore("settings", () => {
const legacy = localStorage.getItem("dbx-ai-config");
const saved = await api.loadAiConfig().catch(() => null);
if (saved) {
aiConfig.value = saved;
aiConfig.value = normalizeAiConfig(saved);
} else if (legacy) {
aiConfig.value = JSON.parse(legacy);
aiConfig.value = normalizeAiConfig(JSON.parse(legacy));
await api.saveAiConfig(aiConfig.value).catch(() => {});
localStorage.removeItem("dbx-ai-config");
}
@@ -196,7 +289,8 @@ export const useSettingsStore = defineStore("settings", () => {
}
function isConfigured(): boolean {
return !!aiConfig.value.apiKey && !!aiConfig.value.endpoint;
const preset = AI_PROVIDER_PRESETS[aiConfig.value.provider];
return !!aiConfig.value.endpoint && !!aiConfig.value.model && (!preset.requiresApiKey || !!aiConfig.value.apiKey);
}
function updateEditorSettings(partial: Partial<EditorSettings>) {