feat(ai): add Jalapeno Cloud partner preset

This commit is contained in:
t8y2
2026-09-01 18:07:16 +08:00
parent 19be194728
commit 6a7ea4ae95
16 changed files with 253 additions and 47 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -55,7 +55,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { useTheme } from "@/composables/useTheme";
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
import { useSettingsStore, AI_PROVIDER_PRESETS, aiProviderLabel, normalizeAiConfig } from "@/stores/settingsStore";
import { useSettingsStore, AI_PROVIDER_PRESETS, aiProviderLabel, getAiProviderPreset, normalizeAiConfig } from "@/stores/settingsStore";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -715,7 +715,7 @@ const activeFullConfig = computed(() => {
});
function isModelCandidate(config: AiConfigItem): boolean {
return isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey, supportsCliProviders);
return isAiConfigModelCandidate(config, getAiProviderPreset(config.provider, config.endpoint).requiresApiKey, supportsCliProviders);
}
function getModelsForConfig(configId: string) {
@@ -724,8 +724,14 @@ function getModelsForConfig(configId: string) {
return aiModelOptions(config, modelCatalogs.get(configId)?.models ?? []);
}
function aiConfigProviderLabel(config: { provider: AiConfigItem["provider"]; endpoint: string } | null | undefined): string {
if (!config) return aiProviderLabel("claude", t);
const preset = getAiProviderPreset(config.provider, config.endpoint);
return preset.provider === "custom" ? aiProviderLabel(config.provider, t) : preset.label;
}
function configMatchesModelQuery(config: AiConfigItem, query: string): boolean {
return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || aiProviderLabel(config.provider, t).toLowerCase().includes(query) || AI_PROVIDER_PRESETS[config.provider].label.toLowerCase().includes(query);
return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || aiConfigProviderLabel(config).toLowerCase().includes(query);
}
function getConfigModelOptions(config: AiConfigItem) {
@@ -5001,7 +5007,13 @@ async function openExternalUrl(url: string) {
<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="activeFullConfig?.provider ?? 'claude'" :label="aiProviderLabel(activeFullConfig?.provider ?? 'claude', t)" :icon-slug="AI_PROVIDER_PRESETS[activeFullConfig?.provider ?? 'claude']?.iconSlug" class="h-3 w-3 shrink-0" />
<AiProviderLogo
:provider="activeFullConfig?.provider ?? 'claude'"
:label="aiConfigProviderLabel(activeFullConfig)"
:icon-slug="activeFullConfig ? getAiProviderPreset(activeFullConfig.provider, activeFullConfig.endpoint).iconSlug : AI_PROVIDER_PRESETS.claude.iconSlug"
:icon-path="activeFullConfig ? getAiProviderPreset(activeFullConfig.provider, activeFullConfig.endpoint).iconPath : undefined"
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>
@@ -5015,7 +5027,13 @@ async function openExternalUrl(url: string) {
<div v-for="(config, configIndex) in configuredProviders" :key="config.id" class="relative">
<button type="button" class="sticky top-0 z-10 flex w-full items-center gap-2 rounded-sm bg-popover px-2 py-1.5 text-left text-xs text-foreground hover:bg-muted" :aria-expanded="!isModelConfigCollapsed(config.id)" @click="toggleModelConfig(config.id)">
<ChevronRight class="h-3.5 w-3.5 shrink-0 transition-transform" :class="{ 'rotate-90': !isModelConfigCollapsed(config.id) }" />
<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" />
<AiProviderLogo
:provider="config.provider"
:label="getAiProviderPreset(config.provider, config.endpoint).label"
:icon-slug="getAiProviderPreset(config.provider, config.endpoint).iconSlug"
:icon-path="getAiProviderPreset(config.provider, config.endpoint).iconPath"
class="h-3.5 w-3.5 shrink-0"
/>
<span class="min-w-0 flex-1 truncate font-medium">{{ config.name }}</span>
<Loader2 v-if="getModelCatalog(config.id).status === 'loading'" class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
<span v-if="config.isDefault" class="ml-auto text-[10px] text-muted-foreground">{{ t("ai.default") }}</span>
@@ -45,7 +45,7 @@ import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -53,7 +53,7 @@ import { HelpTooltip, Tooltip, TooltipContent, TooltipTrigger, TooltipProvider }
import {
useSettingsStore,
AI_PROVIDER_PRESETS,
aiProviderLabel,
AI_PROVIDER_PARTNER_PRESETS,
EDITOR_THEMES,
DEFAULT_EDITOR_SETTINGS,
DEFAULT_DESKTOP_SETTINGS,
@@ -63,6 +63,10 @@ import {
normalizeDuckDbWorkerMaxProcesses,
normalizeAiEnv,
normalizeAiHeaders,
getAiProviderPreset,
getAiProviderPresetId,
getAiProviderPresetOption,
isAiPartnerProviderPreset,
type AiProvider,
type AiApiStyle,
type AiAuthMethod,
@@ -3381,10 +3385,11 @@ const aiDeleteConfigId = ref<string | null>(null);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"]);
const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]);
const CURSOR_CONTROL_ENV = new Set(["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"]);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
const builtinAiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const partnerAiProviderOptions = computed(() => AI_PROVIDER_PARTNER_PRESETS.filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const aiEditProvider = ref<AiProvider>("claude");
const aiEditProviderPresetId = ref("claude");
const aiEditApiKey = ref("");
const aiEditAuthMethod = ref<AiAuthMethod>("api-key");
const aiEditEndpoint = ref("");
@@ -3415,6 +3420,8 @@ const aiEditQoderCliPath = ref("");
const aiEditQoderCliEnvRows = ref<AiEnvRow[]>([]);
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
const selectedAiProviderPreset = computed(() => getAiProviderPresetOption(aiEditProviderPresetId.value));
const selectedAiPartnerPreset = computed(() => (isAiPartnerProviderPreset(selectedAiProviderPreset.value) ? selectedAiProviderPreset.value : null));
const aiTesting = ref(false);
const aiTestResult = ref<"" | "success" | "error">("");
@@ -3515,7 +3522,7 @@ const aiEditCliEnvRows = computed(() => {
watch(aiIsCliProvider, (isCliProvider) => {
if (isCliProvider) void ensureCliMcpStatus();
});
const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey);
const aiRequiresApiKey = computed(() => selectedAiProviderPreset.value.requiresApiKey);
const aiUsesConfigurableAnthropicAuth = computed(() => aiEditProvider.value === "claude" || aiEditProvider.value === "anthropic-compatible" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value));
const aiUsesCompatibleAnthropicApi = computed(() => aiEditProvider.value === "anthropic-compatible" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value));
const aiSupportsAuthMethod = computed(() => aiUsesConfigurableAnthropicAuth.value);
@@ -3707,18 +3714,20 @@ function syncAiEditState() {
aiTestErrorCopied.value = false;
}
function aiSelectProvider(provider: AiProvider) {
function aiSelectProvider(presetId: string) {
const preset = getAiProviderPresetOption(presetId);
const provider = preset.provider;
if (isWeb && CLI_AI_PROVIDERS.has(provider)) return;
if (provider === aiEditProvider.value) return;
if (presetId === aiEditProviderPresetId.value) return;
// Apply new provider's preset defaults to edit state
const preset = AI_PROVIDER_PRESETS[provider];
aiEditProviderPresetId.value = presetId;
aiEditProvider.value = provider;
aiEditApiKey.value = "";
aiEditAuthMethod.value = preset.authMethod;
aiEditEndpoint.value = preset.endpoint;
aiEditModel.value = "";
aiEditLegacyModels.value = [];
aiEditModel.value = preset.group === "partner" ? preset.model : "";
aiEditLegacyModels.value = preset.group === "partner" ? [...(preset.models ?? [])] : [];
aiEditApiStyle.value = preset.apiStyle;
aiEditCustomHeaderRows.value = [];
aiEditEnableThinking.value = true;
@@ -3747,6 +3756,7 @@ function aiEnterEditMode(configId?: string) {
if (config) {
aiEditConfigName.value = config.name;
aiEditProvider.value = config.provider;
aiEditProviderPresetId.value = getAiProviderPresetId(config.provider, config.endpoint);
aiEditApiKey.value = config.apiKey;
aiEditAuthMethod.value = config.authMethod;
aiEditEndpoint.value = config.endpoint;
@@ -3782,6 +3792,7 @@ function aiEnterEditMode(configId?: string) {
} else {
aiEditConfigName.value = "";
aiEditProvider.value = "claude";
aiEditProviderPresetId.value = "claude";
aiEditApiKey.value = "";
aiEditAuthMethod.value = AI_PROVIDER_PRESETS["claude"].authMethod;
aiEditEndpoint.value = AI_PROVIDER_PRESETS["claude"].endpoint;
@@ -3823,6 +3834,7 @@ async function applyPendingAiConfigDeepLinkDraft() {
aiEnterEditMode();
aiEditConfigName.value = draft.name;
aiEditProvider.value = draft.provider;
aiEditProviderPresetId.value = getAiProviderPresetId(draft.provider, draft.endpoint);
aiEditApiKey.value = "";
aiEditAuthMethod.value = draft.authMethod;
aiEditEndpoint.value = draft.endpoint;
@@ -7057,7 +7069,7 @@ onUnmounted(() => {
<div v-else class="space-y-2">
<div v-for="config in displayedAiConfigs" :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" />
<AiProviderLogo :provider="config.provider" :label="getAiProviderPreset(config.provider, config.endpoint).label" :icon-slug="getAiProviderPreset(config.provider, config.endpoint).iconSlug" :icon-path="getAiProviderPreset(config.provider, config.endpoint).iconPath" />
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ config.name }}</span>
@@ -7066,7 +7078,7 @@ onUnmounted(() => {
</Badge>
</div>
<div class="text-xs text-muted-foreground">
{{ aiProviderLabel(config.provider, t) }}
{{ getAiProviderPreset(config.provider, config.endpoint).label }}
</div>
</div>
</div>
@@ -7309,28 +7321,51 @@ onUnmounted(() => {
</div>
<!-- Provider Selection -->
<div class="grid grid-cols-3 items-center gap-3">
<div class="grid grid-cols-3 items-start 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" inputClass="h-8 text-xs">
<SelectValue>
<span class="flex items-center gap-2">
<AiProviderLogo :provider="selectedAiProviderPreset.provider" :label="aiProviderLabel(selectedAiProviderPreset.provider, t)" :icon-slug="selectedAiProviderPreset.iconSlug" />
<span>{{ aiProviderLabel(selectedAiProviderPreset.provider, t) }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="provider in aiProviderOptions" :key="provider.provider" :value="provider.provider">
<span class="flex w-full items-center justify-between gap-4">
<div class="col-span-2 space-y-2">
<Select :model-value="aiEditProviderPresetId" @update:model-value="(v: any) => aiSelectProvider(String(v))">
<SelectTrigger inputClass="h-8 text-xs">
<SelectValue>
<span class="flex items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="aiProviderLabel(provider.provider, t)" :icon-slug="provider.iconSlug" />
<span>{{ aiProviderLabel(provider.provider, t) }}</span>
<AiProviderLogo :provider="selectedAiProviderPreset.provider" :label="selectedAiProviderPreset.label" :icon-slug="selectedAiProviderPreset.iconSlug" :icon-path="selectedAiProviderPreset.iconPath" />
<span>{{ selectedAiProviderPreset.label }}</span>
</span>
</span>
</SelectItem>
</SelectContent>
</Select>
</SelectValue>
</SelectTrigger>
<SelectContent class="w-[32rem] max-w-[calc(100vw-2rem)]">
<div class="grid grid-cols-1 gap-1 sm:grid-cols-2">
<SelectGroup class="min-w-0">
<SelectLabel>{{ t("ai.builtinProviders") }}</SelectLabel>
<SelectItem v-for="provider in builtinAiProviderOptions" :key="provider.provider" :value="provider.provider">
<span class="flex w-full min-w-0 items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" :icon-path="provider.iconPath" />
<span class="truncate">{{ provider.label }}</span>
</span>
</SelectItem>
</SelectGroup>
<SelectGroup v-if="partnerAiProviderOptions.length" class="min-w-0 border-border/60 sm:border-l">
<SelectLabel>{{ t("ai.partnerProviders") }}</SelectLabel>
<SelectItem v-for="provider in partnerAiProviderOptions" :key="provider.id" :value="provider.id">
<span class="flex w-full min-w-0 items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" :icon-path="provider.iconPath" />
<span class="min-w-0 flex-1 truncate">{{ provider.label }}</span>
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] font-normal">{{ t("ai.jalapenoSponsored") }}</Badge>
</span>
</SelectItem>
</SelectGroup>
</div>
</SelectContent>
</Select>
<div v-if="selectedAiPartnerPreset" class="flex items-center gap-3 rounded-md border border-primary/20 bg-primary/5 px-3 py-2">
<div class="min-w-0 flex-1">
<p class="text-[11px] leading-4 text-muted-foreground">{{ t(selectedAiPartnerPreset.descriptionKey) }}</p>
</div>
<Button type="button" variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('ai.visitPartner')" :aria-label="t('ai.visitPartner')" @click="openExternalUrl(selectedAiPartnerPreset.websiteUrl)">
<ExternalLink class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<!-- CLI MCP Status -->
@@ -7388,7 +7423,13 @@ onUnmounted(() => {
<!-- API Key -->
<div v-if="!aiIsCliProvider" 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 class="col-span-2 flex min-w-0 items-center gap-2">
<PasswordInput v-model="aiEditApiKey" autocomplete="off" class="min-w-0 flex-1" inputClass="h-8 text-xs" :placeholder="aiCredentialPlaceholder" />
<Button v-if="selectedAiPartnerPreset" type="button" variant="outline" size="sm" class="h-8 shrink-0 gap-1.5 px-3 text-xs" :title="t('ai.getApiKey')" :aria-label="t('ai.getApiKey')" @click="openExternalUrl(selectedAiPartnerPreset.apiKeyUrl)">
{{ t("ai.getApiKey") }}
<ExternalLink class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Endpoint -->
@@ -9,22 +9,23 @@ const props = defineProps<{
provider: AiProvider;
label: string;
iconSlug?: string;
iconPath?: string;
}>();
const failed = ref(false);
const { isDark } = useTheme();
watch(
() => props.iconSlug,
() => {
failed.value = false;
},
);
watch([() => props.iconSlug, () => props.iconPath], () => {
failed.value = false;
});
const usesWhiteDarkIcon = computed(
() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli" || props.provider === "cursor-cli" || props.provider === "grok-cli",
() =>
!props.iconPath &&
(props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli" || props.provider === "cursor-cli" || props.provider === "grok-cli"),
);
const localIconUrl = computed(() => {
if (props.iconPath) return webPath(props.iconPath);
if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg");
return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : "";
});
+6
View File
@@ -2494,6 +2494,12 @@ export default {
noConfig: "AI is not configured. Please set up your AI provider in Settings > AI.",
provider: "Provider",
providerCustom: "Custom",
builtinProviders: "Built-in support",
partnerProviders: "Premium sponsors",
jalapenoSponsored: "$1 free credit",
jalapenoDescription: "🎉 Enterprise-grade AI trusted by leading teams is now available to you! Try cutting-edge models at 50% off and get more bonus credit when you top up more!",
visitPartner: "Visit partner website",
getApiKey: "Get API key",
providerStatusActive: "Active",
providerStatusConfigured: "Configured",
switchProvider: "Switch Provider",
+6
View File
@@ -2430,6 +2430,12 @@ export default withEnglishFallback({
noConfig: "La IA no está configurada. Configura tu proveedor de IA en Ajustes > IA.",
provider: "Proveedor",
providerCustom: "Personalizado",
builtinProviders: "Compatibilidad integrada",
partnerProviders: "Patrocinadores destacados",
jalapenoSponsored: "1 USD de crédito gratis",
jalapenoDescription: "🎉 ¡La IA de nivel empresarial que utilizan los equipos líderes ya está disponible para ti! ¡Prueba modelos de vanguardia con un 50 % de descuento y recibe más bonificaciones al recargar!",
visitPartner: "Visitar el sitio web del socio",
getApiKey: "Obtener clave API",
providerStatusActive: "Activo",
providerStatusConfigured: "Configurado",
switchProvider: "Cambiar proveedor",
+6
View File
@@ -2428,6 +2428,12 @@ export default withEnglishFallback({
noConfig: "L'AI non è configurata. Configura il tuo provider AI in Impostazioni > AI.",
provider: "Provider",
providerCustom: "Personalizzato",
builtinProviders: "Supporto integrato",
partnerProviders: "Sponsor selezionati",
jalapenoSponsored: "$1 di credito gratuito",
jalapenoDescription: "🎉 L'AI di livello enterprise usata dai team migliori è ora disponibile per te! Prova i modelli all'avanguardia con il 50% di sconto e ottieni più bonus ricaricando di più!",
visitPartner: "Visita il sito del partner",
getApiKey: "Ottieni la chiave API",
providerStatusActive: "Attivo",
providerStatusConfigured: "Configurato",
switchProvider: "Cambia provider",
+6
View File
@@ -2454,6 +2454,12 @@ export default withEnglishFallback({
noConfig: "AIが設定されていません。設定 > AIでAIプロバイダーを設定してください。",
provider: "プロバイダー",
providerCustom: "カスタム",
builtinProviders: "組み込みサポート",
partnerProviders: "優良スポンサー",
jalapenoSponsored: "$1分の無料クレジット",
jalapenoDescription: "🎉トップチームも利用するエンタープライズ向けAIが、あなたのもとへ!最先端モデルを50%オフで今すぐ体験。さらにチャージするほどボーナスも増量!",
visitPartner: "パートナーサイトを開く",
getApiKey: "APIキーを取得",
providerStatusActive: "使用中",
providerStatusConfigured: "設定済み",
switchProvider: "プロバイダーを切替",
+6
View File
@@ -2326,6 +2326,12 @@ export default withEnglishFallback({
noConfig: "AI가 구성되지 않았습니다. 설정 > AI에서 AI 공급자를 설정하세요.",
provider: "공급자",
providerCustom: "사용자 지정",
builtinProviders: "내장 지원",
partnerProviders: "우수 스폰서",
jalapenoSponsored: "$1 무료 크레딧",
jalapenoDescription: "🎉 최고의 팀들이 사용하는 엔터프라이즈급 AI를 이제 만나보세요! 최첨단 모델을 50% 할인으로 바로 체험하고, 충전할수록 더 많은 보너스를 받으세요!",
visitPartner: "파트너 웹사이트 방문",
getApiKey: "API 키 받기",
providerStatusActive: "활성",
providerStatusConfigured: "구성됨",
switchProvider: "공급자 전환",
+6
View File
@@ -2430,6 +2430,12 @@ export default withEnglishFallback({
noConfig: "A AI não está configurada. Configure seu provedor de AI em Configurações > AI.",
provider: "Provedor",
providerCustom: "Personalizado",
builtinProviders: "Suporte integrado",
partnerProviders: "Patrocinadores premium",
jalapenoSponsored: "$1 de crédito grátis",
jalapenoDescription: "🎉 A IA de nível empresarial usada pelas principais equipes agora está disponível para você! Experimente modelos de ponta com 50% de desconto e ganhe mais bônus ao recarregar mais!",
visitPartner: "Visitar site do parceiro",
getApiKey: "Obter chave de API",
providerStatusActive: "Ativo",
providerStatusConfigured: "Configurado",
switchProvider: "Trocar provedor",
+6
View File
@@ -2417,6 +2417,12 @@ export default withEnglishFallback({
noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。",
provider: "提供商",
providerCustom: "自定义",
builtinProviders: "内置支持",
partnerProviders: "优质赞助商",
jalapenoSponsored: "$1 免费额度",
jalapenoDescription: "🎉顶尖团队都在用的企业级 AI,现在为你而来!【5折前沿模型】立刻体验!更有【多充多送】羊毛狠狠薅!",
visitPartner: "访问合作伙伴网站",
getApiKey: "获取 API Key",
providerStatusActive: "当前",
providerStatusConfigured: "已配置",
switchProvider: "切换服务商",
+6
View File
@@ -2427,6 +2427,12 @@ export default withEnglishFallback({
noConfig: "AI 尚未設定。請在 設定 > AI 中設定你的 AI 服務。",
provider: "供應商",
providerCustom: "自訂",
builtinProviders: "內建支援",
partnerProviders: "優質贊助商",
jalapenoSponsored: "$1 免費額度",
jalapenoDescription: "🎉頂尖團隊都在使用的企業級 AI,現在也為你而來!【5 折前沿模型】立即體驗!還有【儲值越多送越多】的優惠!",
visitPartner: "前往合作夥伴網站",
getApiKey: "取得 API Key",
providerStatusActive: "目前",
providerStatusConfigured: "已設定",
switchProvider: "切換服務商",
@@ -29,6 +29,7 @@ describe("settings page navigation", () => {
expect(appSource).toContain(':ai-config-draft="settingsAiConfigDraft"');
expect(appSource).toContain(':ai-config-request-id="settingsAiConfigRequestId"');
expect(settingsDialogSource).toContain("aiEnterEditMode();");
expect(handlerSource).toContain("aiEditProviderPresetId.value = getAiProviderPresetId(draft.provider, draft.endpoint);");
expect(settingsDialogSource).toContain('aiEditApiKey.value = "";');
expect(settingsDialogSource).toContain("importClipboardApiKeyAfterConfirmation");
expect(handlerStart).toBeGreaterThanOrEqual(0);
+54 -2
View File
@@ -170,7 +170,17 @@ export function normalizeDuckDbWorkerMaxProcesses(value: unknown): number {
export interface AiProviderPreset extends Omit<AiConfig, "apiKey"> {
label: string;
iconSlug?: string;
iconPath?: string;
requiresApiKey: boolean;
group?: "builtin" | "partner";
}
export interface AiPartnerProviderPreset extends AiProviderPreset {
id: string;
group: "partner";
websiteUrl: string;
apiKeyUrl: string;
descriptionKey: string;
}
export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
@@ -360,9 +370,51 @@ export function aiProviderLabel(provider: AiProvider, t: (key: string) => string
return AI_PROVIDER_PRESETS[provider].label;
}
export const AI_PROVIDER_PARTNER_PRESETS: readonly AiPartnerProviderPreset[] = [
{
id: "jalapeno-cloud",
label: "Jalapeno Cloud",
iconPath: "/icons/ai/jalapeno-cloud.png",
group: "partner",
provider: "openai-compatible",
endpoint: "https://api.jalapeno-cloud.ai/v1",
model: "GLM-5.2",
models: [{ name: "GLM-5.2" }, { name: "DeepSeek-V4-Pro" }, { name: "MiniMax-M3" }],
apiStyle: "completions",
authMethod: "bearer",
requiresApiKey: true,
websiteUrl: "https://www.jalapeno-cloud.ai/dbx",
apiKeyUrl: "https://www.jalapeno-cloud.ai/dbx",
descriptionKey: "ai.jalapenoDescription",
},
];
function normalizeAiProviderEndpoint(endpoint: string): string {
return endpoint.trim().replace(/\/+$/, "").toLowerCase();
}
export function getAiProviderPreset(provider: AiProvider, endpoint = ""): AiProviderPreset | AiPartnerProviderPreset {
const normalizedEndpoint = normalizeAiProviderEndpoint(endpoint);
const partnerPreset = AI_PROVIDER_PARTNER_PRESETS.find((preset) => preset.provider === provider && normalizeAiProviderEndpoint(preset.endpoint) === normalizedEndpoint);
return partnerPreset ?? AI_PROVIDER_PRESETS[provider];
}
export function getAiProviderPresetOption(id: string): AiProviderPreset | AiPartnerProviderPreset {
return AI_PROVIDER_PARTNER_PRESETS.find((preset) => preset.id === id) ?? AI_PROVIDER_PRESETS[id as AiProvider] ?? AI_PROVIDER_PRESETS.custom;
}
export function getAiProviderPresetId(provider: AiProvider, endpoint = ""): string {
const preset = getAiProviderPreset(provider, endpoint);
return "id" in preset ? preset.id : provider;
}
export function isAiPartnerProviderPreset(preset: AiProviderPreset | AiPartnerProviderPreset): preset is AiPartnerProviderPreset {
return preset.group === "partner";
}
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;
const { label: _label, iconSlug: _iconSlug, iconPath: _iconPath, group: _group, requiresApiKey: _requiresApiKey, ...config } = preset;
return [provider, config];
}),
) as Record<AiProvider, Omit<AiConfig, "apiKey">>;
@@ -1814,7 +1866,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (!activeModel.value) return false;
const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId);
if (!config) return false;
const preset = AI_PROVIDER_PRESETS[config.provider];
const preset = getAiProviderPreset(config.provider, config.endpoint);
if (
config.provider === "codex-cli" ||
config.provider === "claude-code-cli" ||
+29
View File
@@ -1557,6 +1557,16 @@ fn normalized_api_key(config: &AiConfig) -> &str {
config.api_key.trim()
}
fn is_jalapeno_config(config: &AiConfig) -> bool {
if !matches!(config.provider, AiProvider::OpenaiCompatible) {
return false;
}
reqwest::Url::parse(config.endpoint.trim())
.ok()
.and_then(|url| url.host_str().map(|host| host.eq_ignore_ascii_case("api.jalapeno-cloud.ai")))
.unwrap_or(false)
}
fn validate_config(config: &AiConfig) -> Result<(), String> {
crate::ai_effort::validate_runtime_effort(config)?;
if is_cli_provider(&config.provider) {
@@ -1591,6 +1601,9 @@ fn validate_model_list_config(config: &AiConfig) -> Result<(), String> {
pub fn maybe_bearer_headers(config: &AiConfig) -> Result<HeaderMap, String> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if is_jalapeno_config(config) {
headers.insert("HTTP-Referer", HeaderValue::from_static("https://dbxio.com"));
}
let api_key = normalized_api_key(config);
if !api_key.is_empty() {
headers.insert(AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|e| e.to_string())?);
@@ -6148,6 +6161,22 @@ mod tests {
assert!(maybe_bearer_headers(&config).unwrap_err().contains("Invalid AI custom header value"));
}
#[test]
fn jalapeno_requests_include_dbx_referer() {
let mut config = test_config(AiProvider::OpenaiCompatible);
config.endpoint = "https://api.jalapeno-cloud.ai/v1".to_string();
let headers = maybe_bearer_headers(&config).unwrap();
assert_eq!(headers.get("HTTP-Referer").unwrap(), "https://dbxio.com");
config.endpoint = "https://api.jalapeno-cloud.ai.example.com/v1".to_string();
assert!(maybe_bearer_headers(&config).unwrap().get("HTTP-Referer").is_none());
config.endpoint = "https://api.jalapeno-cloud.ai/v1".to_string();
config.provider = AiProvider::Custom;
assert!(maybe_bearer_headers(&config).unwrap().get("HTTP-Referer").is_none());
}
#[test]
fn claude_system_prompt_substitutes_default_when_empty() {
// Empty or whitespace-only prompts must fall back to a non-empty value,
+17 -1
View File
@@ -6,7 +6,7 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS } from "../../apps/desktop/src/lib/sql/s
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS } from "../../apps/desktop/src/lib/table/tableColumnTemplates.ts";
import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY } from "../../apps/desktop/src/lib/app/appFonts.ts";
import { tableOpenPageLimit } from "../../apps/desktop/src/lib/table/tableOpenPageLimit.ts";
import { AI_PROVIDER_PRESETS, DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeAiConfig, normalizeEditorSettings, useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts";
import { AI_PROVIDER_PARTNER_PRESETS, AI_PROVIDER_PRESETS, DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, getAiProviderPreset, getAiProviderPresetId, normalizeAiConfig, normalizeEditorSettings, useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts";
import { DEFAULT_SHORTCUT_SETTINGS, tabNavigationHistoryDefaultShortcut, type ShortcutSettings } from "../../apps/desktop/src/lib/editor/shortcutRegistry.ts";
const saveEditorSettingsMock = vi.hoisted(() => vi.fn());
@@ -874,6 +874,22 @@ test("AI provider presets include common hosted and local providers", () => {
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
});
test("AI partner presets reuse a supported runtime adapter", () => {
const jalapeno = AI_PROVIDER_PARTNER_PRESETS.find((preset) => preset.id === "jalapeno-cloud");
assert.ok(jalapeno);
assert.equal(jalapeno.provider, "openai-compatible");
assert.equal(jalapeno.endpoint, "https://api.jalapeno-cloud.ai/v1");
assert.equal(jalapeno.model, "GLM-5.2");
assert.deepEqual(jalapeno.models, [{ name: "GLM-5.2" }, { name: "DeepSeek-V4-Pro" }, { name: "MiniMax-M3" }]);
assert.equal(jalapeno.requiresApiKey, true);
assert.equal(jalapeno.websiteUrl, "https://www.jalapeno-cloud.ai/dbx");
assert.equal(jalapeno.apiKeyUrl, "https://www.jalapeno-cloud.ai/dbx");
assert.equal(getAiProviderPreset("openai-compatible", "https://api.jalapeno-cloud.ai/v1/").label, "Jalapeno Cloud");
assert.equal(getAiProviderPresetId("openai-compatible", "https://api.jalapeno-cloud.ai/v1/"), "jalapeno-cloud");
assert.equal(getAiProviderPreset("openai-compatible", "https://api.example.com/v1").label, "OpenAI Compatible");
});
test("API AI provider settings expose and persist a default model ID", () => {
const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8");
const modelControl = source.indexOf('<Input v-model="aiEditModel"');