mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
feat(ai): add provider presets
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user