From f253ba8045dc1677d97f6d23bdc769b7a75a4d43 Mon Sep 17 00:00:00 2001 From: Bowen Date: Sat, 7 Mar 2026 01:49:32 +0800 Subject: [PATCH 01/11] add gemini support --- components/ModelSelector.vue | 7 ++ .../GmailTools/GmailComposeCard.vue | 6 +- .../components/GmailTools/GmailReplyCard.vue | 6 +- .../WritingTools/SuggestionCard.vue | 6 +- .../content/composables/useTranslator.ts | 6 +- entrypoints/main-world-injected/llm-api.ts | 2 +- entrypoints/main-world-injected/utils.ts | 7 +- .../components/DebugSettings/index.vue | 1 + .../Blocks/GeminiConfiguration.vue | 115 ++++++++++++++++++ .../components/GeneralSettings/index.vue | 2 + .../BackendSelectionTutorialCard.vue | 12 +- .../sidepanel/components/Onboarding/index.vue | 49 ++++++-- entrypoints/sidepanel/utils/agent/index.ts | 6 +- types/scroll-targets.ts | 2 +- utils/llm/gemini.ts | 19 +++ utils/llm/models.ts | 30 ++++- utils/pinia-store/store.ts | 19 +++ utils/rpc/background-fns.ts | 4 + utils/rpc/content-main-world-fns.ts | 5 + utils/translation-cache/key-strategy.ts | 2 +- utils/user-config/index.ts | 8 ++ 21 files changed, 284 insertions(+), 30 deletions(-) create mode 100644 entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue create mode 100644 utils/llm/gemini.ts diff --git a/components/ModelSelector.vue b/components/ModelSelector.vue index b975fd7f..bec62431 100644 --- a/components/ModelSelector.vue +++ b/components/ModelSelector.vue @@ -205,6 +205,7 @@ const modelListUpdating = computed(() => { const modelOptions = computed(() => { const ollamaModels = modelList.value.filter((model) => model.backend === 'ollama') const lmStudioModels = modelList.value.filter((model) => model.backend === 'lm-studio') + const geminiModels = modelList.value.filter((model) => model.backend === 'gemini') const webllmModels = modelList.value.filter((model) => model.backend === 'web-llm') const makeModelOptions = (model: typeof modelList.value[number]) => ({ type: 'option' as const, id: `${model.backend}#${model.model}`, label: model.name, model: { backend: model.backend, id: model.model } }) @@ -226,6 +227,12 @@ const modelOptions = computed(() => { ...lmStudioModels.map((model) => makeModelOptions(model)), ) } + if (geminiModels.length) { + options.push( + makeHeader(`Gemini Models (${geminiModels.length})`), + ...geminiModels.map((model) => makeModelOptions(model)), + ) + } return options } }) diff --git a/entrypoints/content/components/GmailTools/GmailComposeCard.vue b/entrypoints/content/components/GmailTools/GmailComposeCard.vue index e5ad6f12..5e367fd7 100644 --- a/entrypoints/content/components/GmailTools/GmailComposeCard.vue +++ b/entrypoints/content/components/GmailTools/GmailComposeCard.vue @@ -380,7 +380,11 @@ async function checkLLMBackendStatus() { } else if (status === 'backend-unavailable') { toast(t('errors.model_request_error'), { duration: 2000 }) - endpointType === 'ollama' ? showSettings({ scrollTarget: 'ollama-server-address-section' }) : showSettings({ scrollTarget: 'lm-studio-server-address-section' }) + endpointType === 'ollama' + ? showSettings({ scrollTarget: 'ollama-server-address-section' }) + : endpointType === 'lm-studio' + ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) + : showSettings({ scrollTarget: 'gemini-api-config-section' }) emit('close') return false } diff --git a/entrypoints/content/components/GmailTools/GmailReplyCard.vue b/entrypoints/content/components/GmailTools/GmailReplyCard.vue index d09d8641..e2b2223b 100644 --- a/entrypoints/content/components/GmailTools/GmailReplyCard.vue +++ b/entrypoints/content/components/GmailTools/GmailReplyCard.vue @@ -299,7 +299,11 @@ async function checkLLMBackendStatus() { } else if (status === 'backend-unavailable') { toast(t('errors.model_request_error'), { duration: 2000 }) - endpointType === 'ollama' ? showSettings({ scrollTarget: 'ollama-server-address-section' }) : showSettings({ scrollTarget: 'lm-studio-server-address-section' }) + endpointType === 'ollama' + ? showSettings({ scrollTarget: 'ollama-server-address-section' }) + : endpointType === 'lm-studio' + ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) + : showSettings({ scrollTarget: 'gemini-api-config-section' }) emit('close') return false } diff --git a/entrypoints/content/components/WritingTools/SuggestionCard.vue b/entrypoints/content/components/WritingTools/SuggestionCard.vue index 18a9893b..640613df 100644 --- a/entrypoints/content/components/WritingTools/SuggestionCard.vue +++ b/entrypoints/content/components/WritingTools/SuggestionCard.vue @@ -163,7 +163,11 @@ async function checkLLMBackendStatus() { } else if (status === 'backend-unavailable') { toast(t('errors.model_request_error'), { duration: 2000 }) - endpointType === 'ollama' ? showSettings({ scrollTarget: 'ollama-server-address-section' }) : showSettings({ scrollTarget: 'lm-studio-server-address-section' }) + endpointType === 'ollama' + ? showSettings({ scrollTarget: 'ollama-server-address-section' }) + : endpointType === 'lm-studio' + ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) + : showSettings({ scrollTarget: 'gemini-api-config-section' }) emit('close') return false } diff --git a/entrypoints/content/composables/useTranslator.ts b/entrypoints/content/composables/useTranslator.ts index 8a6bac90..a95c93c3 100644 --- a/entrypoints/content/composables/useTranslator.ts +++ b/entrypoints/content/composables/useTranslator.ts @@ -73,7 +73,11 @@ async function _useTranslator() { const { status, endpointType } = await llmBackendStatusStore.checkCurrentBackendStatus() if (status === 'backend-unavailable') { toast('Failed to connect to Ollama server, please check your Ollama connection', { duration: 2000 }) - endpointType === 'ollama' ? showSettings({ scrollTarget: `ollama-server-address-section` }) : showSettings({ scrollTarget: `lm-studio-server-address-section` }) + endpointType === 'ollama' + ? showSettings({ scrollTarget: 'ollama-server-address-section' }) + : endpointType === 'lm-studio' + ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) + : showSettings({ scrollTarget: 'gemini-api-config-section' }) return } else if (status === 'no-model') { diff --git a/entrypoints/main-world-injected/llm-api.ts b/entrypoints/main-world-injected/llm-api.ts index 4787bbc2..c540bef7 100644 --- a/entrypoints/main-world-injected/llm-api.ts +++ b/entrypoints/main-world-injected/llm-api.ts @@ -43,7 +43,7 @@ export class LLMResponses { async create(params: ResponseCreateParamsStreaming): Promise async create(params: ResponseCreateParamsBase): Promise { const readyStatus = await checkBackendModel(params.model) - if (!readyStatus.backend) throw new Error('ollama is not connected') + if (!readyStatus.backend) throw new Error('backend is not connected') if (!readyStatus.model) throw new Error('model is not ready') if (params.stream) { return this.createStreamingResponse(params as ResponseCreateParamsStreaming) diff --git a/entrypoints/main-world-injected/utils.ts b/entrypoints/main-world-injected/utils.ts index b424cc3e..5a9d4991 100644 --- a/entrypoints/main-world-injected/utils.ts +++ b/entrypoints/main-world-injected/utils.ts @@ -21,10 +21,13 @@ export async function getBrowserAIConfig() { export async function checkBackendModel(model?: string) { const status = await m2cRpc.checkBackendModelReady(model) if (!status.backend || !status.model) { + const modelUnavailableMessage = model + ? `Model [${model}] is not available in current provider settings.` + : 'Model is not available in current provider settings.' await m2cRpc.emit('toast', { - message: !status.backend ? 'This page relies on the AI backend provided by Nativemind. Please ensure the backend is running.' : `Model [${model}] is not available. Please download the model from ollama.com.`, + message: !status.backend ? 'This page relies on the AI backend provided by Nativemind. Please ensure the backend is running.' : modelUnavailableMessage, type: 'error', - isHTML: true, + isHTML: false, duration: 5000, }) } diff --git a/entrypoints/settings/components/DebugSettings/index.vue b/entrypoints/settings/components/DebugSettings/index.vue index eff73c55..f8b5c48a 100644 --- a/entrypoints/settings/components/DebugSettings/index.vue +++ b/entrypoints/settings/components/DebugSettings/index.vue @@ -630,6 +630,7 @@ const articles = ref<{ type: 'html' | 'pdf', url: string, title: string, content const modelProviderOptions = [ { id: 'ollama' as const, label: 'Ollama' }, { id: 'lm-studio' as const, label: 'LM Studio' }, + { id: 'gemini' as const, label: 'Gemini API' }, { id: 'web-llm' as const, label: 'Web LLM' }, ] diff --git a/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue new file mode 100644 index 00000000..2806224c --- /dev/null +++ b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue @@ -0,0 +1,115 @@ + + + diff --git a/entrypoints/settings/components/GeneralSettings/index.vue b/entrypoints/settings/components/GeneralSettings/index.vue index 2beaa682..45773d70 100644 --- a/entrypoints/settings/components/GeneralSettings/index.vue +++ b/entrypoints/settings/components/GeneralSettings/index.vue @@ -28,6 +28,7 @@
+
(), { + initialEndpointType: 'ollama', +}) + const emit = defineEmits<{ (event: 'installed', backend: 'ollama' | 'lm-studio'): void - (event: 'settings'): void + (event: 'settings', backend: 'ollama' | 'lm-studio'): void }>() const llmBackendStatusStore = useLLMBackendStatusStore() -const selectedEndpointType = ref<'ollama' | 'lm-studio'>('ollama') +const selectedEndpointType = ref<'ollama' | 'lm-studio'>(props.initialEndpointType) const { t } = useI18n() const selectedEndpointName = computed(() => { @@ -204,6 +210,6 @@ const reScanOllama = async () => { } const onClickOpenSettings = () => { - emit('settings') + emit('settings', selectedEndpointType.value) } diff --git a/entrypoints/sidepanel/components/Onboarding/index.vue b/entrypoints/sidepanel/components/Onboarding/index.vue index 32cf3016..986486c1 100644 --- a/entrypoints/sidepanel/components/Onboarding/index.vue +++ b/entrypoints/sidepanel/components/Onboarding/index.vue @@ -9,7 +9,7 @@
@@ -33,6 +33,7 @@ class="bg-bg-primary rounded-lg overflow-hidden grow flex flex-col justify-between font" > @@ -83,9 +84,9 @@ const llmBackendStatusStore = useLLMBackendStatusStore() const endpointType = userConfig.llm.endpointType.toRef() const onboardingVersion = userConfig.ui.onboarding.version.toRef() const panel = ref<'tutorial' | 'model-downloader'>('tutorial') -const downloadEndpointType = ref<'ollama' | 'lm-studio'>('ollama') +const downloadEndpointType = ref<'ollama' | 'lm-studio'>(endpointType.value === 'lm-studio' ? 'lm-studio' : 'ollama') const isShow = computed(() => { - return onboardingVersion.value !== TARGET_ONBOARDING_VERSION + return false }) const onBackendInstalled = async (backend: 'ollama' | 'lm-studio') => { @@ -100,16 +101,28 @@ const onBackendInstalled = async (backend: 'ollama' | 'lm-studio') => { } } -const onOpenSettings = async () => { - endpointType.value = 'ollama' +const onOpenSettings = async (backend: 'ollama' | 'lm-studio') => { + endpointType.value = backend + downloadEndpointType.value = backend await close() showSettings() } const onModelDownloaderFinished = async () => { - endpointType.value = 'ollama' - await llmBackendStatusStore.updateOllamaConnectionStatus() - await llmBackendStatusStore.updateOllamaModelList() + const backend = downloadEndpointType.value + endpointType.value = backend + if (backend === 'ollama') { + await llmBackendStatusStore.updateOllamaConnectionStatus() + await llmBackendStatusStore.updateOllamaModelList() + } + else { + await llmBackendStatusStore.updateLMStudioConnectionStatus() + await llmBackendStatusStore.updateLMStudioModelList() + } + await close() +} + +const onCloseOnboarding = async () => { await close() } @@ -138,10 +151,22 @@ const close = async () => { onMounted(async () => { if (isShow.value) { - const ollamaSuccess = await llmBackendStatusStore.updateOllamaConnectionStatus() - if (ollamaSuccess) return onBackendInstalled('ollama') - const lmStudioSuccess = await llmBackendStatusStore.updateLMStudioConnectionStatus() - if (lmStudioSuccess) return onBackendInstalled('lm-studio') + if (endpointType.value !== 'ollama' && endpointType.value !== 'lm-studio') return + + const preferredBackend = endpointType.value + const fallbackBackend = preferredBackend === 'ollama' ? 'lm-studio' : 'ollama' + const tryBackend = async (backend: 'ollama' | 'lm-studio') => { + const success = backend === 'ollama' + ? await llmBackendStatusStore.updateOllamaConnectionStatus() + : await llmBackendStatusStore.updateLMStudioConnectionStatus() + if (success) { + await onBackendInstalled(backend) + return true + } + return false + } + if (await tryBackend(preferredBackend)) return + if (await tryBackend(fallbackBackend)) return } }) diff --git a/entrypoints/sidepanel/utils/agent/index.ts b/entrypoints/sidepanel/utils/agent/index.ts index 13481326..87a35641 100644 --- a/entrypoints/sidepanel/utils/agent/index.ts +++ b/entrypoints/sidepanel/utils/agent/index.ts @@ -418,7 +418,8 @@ export class Agent { const { t } = await useGlobalI18n() const errorMsg = await agentMessageManager.convertToAssistantMessage() errorMsg.isError = true - errorMsg.content = t('errors.model_not_found', { endpointType: error.endpointType === 'ollama' ? 'Ollama' : 'LM Studio' }) + const endpointTypeName = error.endpointType === 'ollama' ? 'Ollama' : error.endpointType === 'lm-studio' ? 'LM Studio' : 'Gemini' + errorMsg.content = t('errors.model_not_found', { endpointType: endpointTypeName }) // unresolvable error, break the loop return false } @@ -426,7 +427,8 @@ export class Agent { const { t } = await useGlobalI18n() const errorMsg = await agentMessageManager.convertToAssistantMessage() errorMsg.isError = true - errorMsg.content = t('errors.model_request_error', { endpointType: error.endpointType === 'ollama' ? 'Ollama' : 'LM Studio' }) + const endpointTypeName = error.endpointType === 'ollama' ? 'Ollama' : error.endpointType === 'lm-studio' ? 'LM Studio' : 'Gemini' + errorMsg.content = t('errors.model_request_error', { endpointType: endpointTypeName }) return false } else if (error instanceof LMStudioLoadModelError) { diff --git a/types/scroll-targets.ts b/types/scroll-targets.ts index 1d052ffb..fa402261 100644 --- a/types/scroll-targets.ts +++ b/types/scroll-targets.ts @@ -1 +1 @@ -export type SettingsScrollTarget = 'quick-actions-block' | 'model-download-section' | 'ollama-server-address-section' | 'lm-studio-server-address-section' +export type SettingsScrollTarget = 'quick-actions-block' | 'model-download-section' | 'ollama-server-address-section' | 'lm-studio-server-address-section' | 'gemini-api-config-section' diff --git a/utils/llm/gemini.ts b/utils/llm/gemini.ts new file mode 100644 index 00000000..55f97a6c --- /dev/null +++ b/utils/llm/gemini.ts @@ -0,0 +1,19 @@ +export const GEMINI_MODELS = [ + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + }, + { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + }, + { + id: 'gemini-2.0-flash', + name: 'Gemini 2.0 Flash', + }, +] as const + +export function isGeminiModel(modelId: string | undefined | null): boolean { + if (!modelId) return false + return GEMINI_MODELS.some((model) => model.id === modelId) +} diff --git a/utils/llm/models.ts b/utils/llm/models.ts index 1ea21bd7..b59de6ab 100644 --- a/utils/llm/models.ts +++ b/utils/llm/models.ts @@ -1,3 +1,4 @@ +import { createOpenAICompatible } from '@ai-sdk/openai-compatible' import { LanguageModelV1, wrapLanguageModel } from 'ai' import type { ReasoningOption } from '@/types/reasoning' @@ -6,6 +7,7 @@ import { getUserConfig } from '@/utils/user-config' import { ModelNotFoundError } from '../error' import { makeCustomFetch } from '../fetch' import logger from '../logger' +import { GEMINI_MODELS, isGeminiModel } from './gemini' import { loadModel as loadLMStudioModel } from './lm-studio' import { middlewares } from './middlewares' import { checkModelSupportThinking } from './ollama' @@ -22,10 +24,15 @@ export async function getModelUserConfig(overrides?: { model?: string, endpointT const endpointType = overrides?.endpointType ?? userConfig.llm.endpointType.get() const model = overrides?.model ?? userConfig.llm.model.get() - const baseUrl = userConfig.llm.backends[endpointType === 'lm-studio' ? 'lmStudio' : 'ollama'].baseUrl.get() + const backendKey = endpointType === 'lm-studio' + ? 'lmStudio' + : endpointType === 'gemini' + ? 'gemini' + : 'ollama' + const baseUrl = userConfig.llm.backends[backendKey].baseUrl.get() const apiKey = userConfig.llm.apiKey.get() - const numCtx = userConfig.llm.backends[endpointType === 'lm-studio' ? 'lmStudio' : 'ollama'].numCtx.get() - const enableNumCtx = userConfig.llm.backends[endpointType === 'lm-studio' ? 'lmStudio' : 'ollama'].enableNumCtx.get() + const numCtx = userConfig.llm.backends[backendKey].numCtx.get() + const enableNumCtx = userConfig.llm.backends[backendKey].enableNumCtx.get() const reasoningPreference = userConfig.llm.reasoning.get() const reasoning = getReasoningOptionForModel(reasoningPreference, model) if (!model) { @@ -117,6 +124,15 @@ export async function getModel(options: { { supportsStructuredOutputs: true, provider: 'web-llm', defaultObjectGenerationMode: 'json' }, ) } + else if (endpointType === 'gemini') { + const normalizedBaseUrl = options.baseUrl.endsWith('/') ? options.baseUrl.slice(0, -1) : options.baseUrl + const gemini = createOpenAICompatible({ + name: 'gemini', + baseURL: normalizedBaseUrl, + apiKey: options.apiKey, + }) + model = gemini.chatModel(options.model) + } else { throw new Error('Unsupported endpoint type ' + endpointType) } @@ -126,7 +142,7 @@ export async function getModel(options: { }) } -export type LLMEndpointType = 'ollama' | 'lm-studio' | 'web-llm' +export type LLMEndpointType = 'ollama' | 'lm-studio' | 'web-llm' | 'gemini' export function parseErrorMessageFromChunk(error: unknown): string | null { if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { @@ -140,3 +156,9 @@ export function isModelSupportPDFToImages(_model: string): boolean { // but it's too slow to process large number of image so we disable this feature temporarily by returning false here return false } + +export function getGeminiModels() { + return GEMINI_MODELS +} + +export { isGeminiModel } diff --git a/utils/pinia-store/store.ts b/utils/pinia-store/store.ts index e7865a93..0b21e8e9 100644 --- a/utils/pinia-store/store.ts +++ b/utils/pinia-store/store.ts @@ -3,6 +3,7 @@ import { computed, ref } from 'vue' import { LMStudioModelInfo } from '@/types/lm-studio-models' import { OllamaModelInfo } from '@/types/ollama-models' +import { GEMINI_MODELS, isGeminiModel } from '@/utils/llm/gemini' import { logger } from '@/utils/logger' import { c2bRpc, s2bRpc, settings2bRpc } from '@/utils/rpc' @@ -134,6 +135,7 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => return !!modelInfo?.vision } else { + if (endpointType === 'gemini') return isGeminiModel(currentModel) return false } } @@ -162,6 +164,11 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => model: m.modelKey, name: m.displayName ?? m.modelKey, })), + ...GEMINI_MODELS.map((m) => ({ + backend: 'gemini' as const, + model: m.id, + name: m.name, + })), ] }) @@ -203,6 +210,18 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => } else { status = 'backend-unavailable' } } + else if (endpointType === 'gemini') { + if (isGeminiModel(commonModelConfig.get())) { + status = 'ok' + } + else if (GEMINI_MODELS.length > 0) { + commonModelConfig.set(GEMINI_MODELS[0].id) + status = 'ok' + } + else { + status = 'no-model' + } + } return { modelList, commonModel: commonModelConfig.get(), status, endpointType } } diff --git a/utils/rpc/background-fns.ts b/utils/rpc/background-fns.ts index b024aa8d..ae6d7b29 100644 --- a/utils/rpc/background-fns.ts +++ b/utils/rpc/background-fns.ts @@ -676,6 +676,7 @@ async function checkModelReady(modelId: string) { const endpointType = userConfig.llm.endpointType.get() if (endpointType === 'ollama') return true else if (endpointType === 'lm-studio') return true + else if (endpointType === 'gemini') return true else if (endpointType === 'web-llm') { return await hasWebLLMModelInCache(modelId as WebLLMSupportedModel) } @@ -697,6 +698,9 @@ async function initCurrentModel() { else if (endpointType === 'lm-studio') { return false } + else if (endpointType === 'gemini') { + return false + } else if (endpointType === 'web-llm') { const connectInfo = initWebLLMEngine(model as WebLLMSupportedModel) return connectInfo.portName diff --git a/utils/rpc/content-main-world-fns.ts b/utils/rpc/content-main-world-fns.ts index 57892b9b..4cb75ae0 100644 --- a/utils/rpc/content-main-world-fns.ts +++ b/utils/rpc/content-main-world-fns.ts @@ -4,6 +4,7 @@ import { browser } from 'wxt/browser' import { readPortMessageIntoIterator } from '../async' import { UnsupportedEndpointType } from '../error' +import { isGeminiModel } from '../llm/gemini' import { logger } from '../logger' import { showSettings } from '../settings' import { getUserConfig } from '../user-config' @@ -98,6 +99,10 @@ export async function checkBackendModelReady(model?: string): Promise<{ backend: else if (userConfig.llm.endpointType.get() === 'web-llm') { return { backend: true, model: await c2bRpc.hasWebLLMModelInCache('Qwen3-0.6B-q4f16_1-MLC') } } + else if (userConfig.llm.endpointType.get() === 'gemini') { + const configuredModel = model ?? userConfig.llm.model.get() + return { backend: true, model: isGeminiModel(configuredModel) } + } else { throw new UnsupportedEndpointType(userConfig.llm.endpointType.get()) } diff --git a/utils/translation-cache/key-strategy.ts b/utils/translation-cache/key-strategy.ts index 03e4e0a6..f389a505 100644 --- a/utils/translation-cache/key-strategy.ts +++ b/utils/translation-cache/key-strategy.ts @@ -94,7 +94,7 @@ function extractModelName(modelId: string): string { // Remove common prefixes first const modelName = modelId .toLowerCase() - .replace(/^(ollama|webllm|openai|anthropic|chrome-ai)[/:]?/, '') + .replace(/^(ollama|webllm|openai|anthropic|chrome-ai|gemini)[/:]?/, '') // Extract base model name by removing version suffixes and parameter specifications // Split on both '-' and ':' to handle patterns like "deepseek-r1:32b" diff --git a/utils/user-config/index.ts b/utils/user-config/index.ts index 90d37d54..d4807d75 100644 --- a/utils/user-config/index.ts +++ b/utils/user-config/index.ts @@ -119,6 +119,11 @@ export async function _getUserConfig() { enableNumCtx: await new Config('llm.backends.lmStudio.enableNumCtx').default(enableNumCtx).build(), baseUrl: await new Config('llm.backends.lmStudio.baseUrl').default('http://localhost:1234/api').build(), }, + gemini: { + numCtx: await new Config('llm.backends.gemini.numCtx').default(1024 * 8).build(), + enableNumCtx: await new Config('llm.backends.gemini.enableNumCtx').default(false).build(), + baseUrl: await new Config('llm.backends.gemini.baseUrl').default('https://generativelanguage.googleapis.com/v1beta/openai').build(), + }, }, }, browserAI: { @@ -210,6 +215,9 @@ export async function _getUserConfig() { lmStudioConfig: { open: await new Config('settings.blocks.lmStudioConfig.open').default(true).build(), }, + geminiConfig: { + open: await new Config('settings.blocks.geminiConfig.open').default(true).build(), + }, }, }, emailTools: { From b1c804823e9bbe8a50f1324f209a5514695f5060 Mon Sep 17 00:00:00 2001 From: Bowen Date: Sat, 7 Mar 2026 01:56:13 +0800 Subject: [PATCH 02/11] add openai support --- components/ModelSelector.vue | 15 ++ .../GmailTools/GmailComposeCard.vue | 4 +- .../components/GmailTools/GmailReplyCard.vue | 4 +- .../WritingTools/SuggestionCard.vue | 4 +- .../content/composables/useTranslator.ts | 4 +- .../components/DebugSettings/index.vue | 1 + .../Blocks/GeminiConfiguration.vue | 61 ++++++- .../Blocks/OpenAIConfiguration.vue | 172 ++++++++++++++++++ .../components/GeneralSettings/index.vue | 2 + entrypoints/sidepanel/utils/agent/index.ts | 15 +- entrypoints/sidepanel/utils/chat/chat.ts | 12 +- entrypoints/sidepanel/utils/llm.ts | 18 +- types/scroll-targets.ts | 2 +- utils/llm/models.ts | 36 +++- utils/llm/openai.ts | 23 +++ utils/pinia-store/store.ts | 60 +++++- utils/rpc/background-fns.ts | 25 ++- utils/rpc/content-main-world-fns.ts | 5 + utils/user-config/index.ts | 12 ++ 19 files changed, 449 insertions(+), 26 deletions(-) create mode 100644 entrypoints/settings/components/GeneralSettings/Blocks/OpenAIConfiguration.vue create mode 100644 utils/llm/openai.ts diff --git a/components/ModelSelector.vue b/components/ModelSelector.vue index bec62431..5c326835 100644 --- a/components/ModelSelector.vue +++ b/components/ModelSelector.vue @@ -176,6 +176,8 @@ const userConfig = await getUserConfig() const ollamaBaseUrl = userConfig.llm.backends.ollama.baseUrl.toRef() const lmStudioBaseUrl = userConfig.llm.backends.lmStudio.baseUrl.toRef() const commonModel = userConfig.llm.model.toRef() +const geminiModel = userConfig.llm.backends.gemini.model.toRef() +const openaiModel = userConfig.llm.backends.openai.model.toRef() const translationModel = userConfig.translation.model.toRef() const endpointType = userConfig.llm.endpointType.toRef() const translationEndpointType = userConfig.translation.endpointType.toRef() @@ -206,6 +208,7 @@ const modelOptions = computed(() => { const ollamaModels = modelList.value.filter((model) => model.backend === 'ollama') const lmStudioModels = modelList.value.filter((model) => model.backend === 'lm-studio') const geminiModels = modelList.value.filter((model) => model.backend === 'gemini') + const openaiModels = modelList.value.filter((model) => model.backend === 'openai') const webllmModels = modelList.value.filter((model) => model.backend === 'web-llm') const makeModelOptions = (model: typeof modelList.value[number]) => ({ type: 'option' as const, id: `${model.backend}#${model.model}`, label: model.name, model: { backend: model.backend, id: model.model } }) @@ -233,6 +236,12 @@ const modelOptions = computed(() => { ...geminiModels.map((model) => makeModelOptions(model)), ) } + if (openaiModels.length) { + options.push( + makeHeader(`OpenAI Models (${openaiModels.length})`), + ...openaiModels.map((model) => makeModelOptions(model)), + ) + } return options } }) @@ -251,6 +260,12 @@ const selectedModel = computed({ if (props.modelType === 'chat') { commonModel.value = modelInfo.model.id endpointType.value = modelInfo.model.backend as LLMEndpointType + if (modelInfo.model.backend === 'gemini') { + geminiModel.value = modelInfo.model.id + } + else if (modelInfo.model.backend === 'openai') { + openaiModel.value = modelInfo.model.id + } } else { translationModel.value = modelInfo.model.id diff --git a/entrypoints/content/components/GmailTools/GmailComposeCard.vue b/entrypoints/content/components/GmailTools/GmailComposeCard.vue index 5e367fd7..d1f331ec 100644 --- a/entrypoints/content/components/GmailTools/GmailComposeCard.vue +++ b/entrypoints/content/components/GmailTools/GmailComposeCard.vue @@ -384,7 +384,9 @@ async function checkLLMBackendStatus() { ? showSettings({ scrollTarget: 'ollama-server-address-section' }) : endpointType === 'lm-studio' ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) - : showSettings({ scrollTarget: 'gemini-api-config-section' }) + : endpointType === 'gemini' + ? showSettings({ scrollTarget: 'gemini-api-config-section' }) + : showSettings({ scrollTarget: 'openai-api-config-section' }) emit('close') return false } diff --git a/entrypoints/content/components/GmailTools/GmailReplyCard.vue b/entrypoints/content/components/GmailTools/GmailReplyCard.vue index e2b2223b..d0e4eb90 100644 --- a/entrypoints/content/components/GmailTools/GmailReplyCard.vue +++ b/entrypoints/content/components/GmailTools/GmailReplyCard.vue @@ -303,7 +303,9 @@ async function checkLLMBackendStatus() { ? showSettings({ scrollTarget: 'ollama-server-address-section' }) : endpointType === 'lm-studio' ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) - : showSettings({ scrollTarget: 'gemini-api-config-section' }) + : endpointType === 'gemini' + ? showSettings({ scrollTarget: 'gemini-api-config-section' }) + : showSettings({ scrollTarget: 'openai-api-config-section' }) emit('close') return false } diff --git a/entrypoints/content/components/WritingTools/SuggestionCard.vue b/entrypoints/content/components/WritingTools/SuggestionCard.vue index 640613df..380bc2ed 100644 --- a/entrypoints/content/components/WritingTools/SuggestionCard.vue +++ b/entrypoints/content/components/WritingTools/SuggestionCard.vue @@ -167,7 +167,9 @@ async function checkLLMBackendStatus() { ? showSettings({ scrollTarget: 'ollama-server-address-section' }) : endpointType === 'lm-studio' ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) - : showSettings({ scrollTarget: 'gemini-api-config-section' }) + : endpointType === 'gemini' + ? showSettings({ scrollTarget: 'gemini-api-config-section' }) + : showSettings({ scrollTarget: 'openai-api-config-section' }) emit('close') return false } diff --git a/entrypoints/content/composables/useTranslator.ts b/entrypoints/content/composables/useTranslator.ts index a95c93c3..130c4a20 100644 --- a/entrypoints/content/composables/useTranslator.ts +++ b/entrypoints/content/composables/useTranslator.ts @@ -77,7 +77,9 @@ async function _useTranslator() { ? showSettings({ scrollTarget: 'ollama-server-address-section' }) : endpointType === 'lm-studio' ? showSettings({ scrollTarget: 'lm-studio-server-address-section' }) - : showSettings({ scrollTarget: 'gemini-api-config-section' }) + : endpointType === 'gemini' + ? showSettings({ scrollTarget: 'gemini-api-config-section' }) + : showSettings({ scrollTarget: 'openai-api-config-section' }) return } else if (status === 'no-model') { diff --git a/entrypoints/settings/components/DebugSettings/index.vue b/entrypoints/settings/components/DebugSettings/index.vue index f8b5c48a..2a1d0e68 100644 --- a/entrypoints/settings/components/DebugSettings/index.vue +++ b/entrypoints/settings/components/DebugSettings/index.vue @@ -631,6 +631,7 @@ const modelProviderOptions = [ { id: 'ollama' as const, label: 'Ollama' }, { id: 'lm-studio' as const, label: 'LM Studio' }, { id: 'gemini' as const, label: 'Gemini API' }, + { id: 'openai' as const, label: 'OpenAI API' }, { id: 'web-llm' as const, label: 'Web LLM' }, ] diff --git a/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue index 2806224c..fc908173 100644 --- a/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue +++ b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue @@ -4,6 +4,7 @@ import { computed } from 'vue' import Checkbox from '@/components/Checkbox.vue' import Input from '@/components/Input.vue' import ScrollTarget from '@/components/ScrollTarget.vue' +import Selector from '@/components/Selector.vue' import Button from '@/components/ui/Button.vue' import { SettingsScrollTarget } from '@/types/scroll-targets' import { GEMINI_MODELS, isGeminiModel } from '@/utils/llm/gemini' @@ -19,20 +20,55 @@ defineProps<{ const userConfig = await getUserConfig() const endpointType = userConfig.llm.endpointType.toRef() -const model = userConfig.llm.model.toRef() +const model = userConfig.llm.backends.gemini.model.toRef() +const commonModel = userConfig.llm.model.toRef() const baseUrl = userConfig.llm.backends.gemini.baseUrl.toRef() -const apiKey = userConfig.llm.apiKey.toRef() +const apiKey = userConfig.llm.backends.gemini.apiKey.toRef() +const commonApiKey = userConfig.llm.apiKey.toRef() const numCtx = userConfig.llm.backends.gemini.numCtx.toRef() const enableNumCtx = userConfig.llm.backends.gemini.enableNumCtx.toRef() const open = userConfig.settings.blocks.geminiConfig.open.toRef() const isCurrentEndpoint = computed(() => endpointType.value === 'gemini') +const customModelOption = computed(() => { + if (!model.value || isGeminiModel(model.value)) return undefined + return { + id: model.value, + label: `${model.value} (Custom)`, + value: model.value, + } +}) +const presetModelOptions = computed(() => { + const presetOptions = GEMINI_MODELS.map((item) => ({ + id: item.id, + label: item.name, + value: item.id, + })) + if (customModelOption.value) { + return [customModelOption.value, ...presetOptions] + } + return presetOptions +}) +const selectedPresetModel = computed({ + get: () => model.value, + set: (value?: string) => { + if (value) model.value = value + }, +}) +const modelInput = computed({ + get: () => model.value ?? '', + set: (value: string) => { + model.value = value.trim() + }, +}) const useGemini = () => { endpointType.value = 'gemini' + commonApiKey.value = apiKey.value if (!isGeminiModel(model.value)) { model.value = GEMINI_MODELS[0]?.id } + commonModel.value = model.value } @@ -93,6 +129,27 @@ const useGemini = () => {
+
+
+
+ +
+ + +
+
+
+import { computed } from 'vue' + +import Checkbox from '@/components/Checkbox.vue' +import Input from '@/components/Input.vue' +import ScrollTarget from '@/components/ScrollTarget.vue' +import Selector from '@/components/Selector.vue' +import Button from '@/components/ui/Button.vue' +import { SettingsScrollTarget } from '@/types/scroll-targets' +import { isOpenAIModel, OPENAI_MODELS } from '@/utils/llm/openai' +import { getUserConfig } from '@/utils/user-config' + +import Block from '../../Block.vue' +import SavedMessage from '../../SavedMessage.vue' +import Section from '../../Section.vue' + +defineProps<{ + scrollTarget?: SettingsScrollTarget +}>() + +const userConfig = await getUserConfig() +const endpointType = userConfig.llm.endpointType.toRef() +const model = userConfig.llm.backends.openai.model.toRef() +const commonModel = userConfig.llm.model.toRef() +const baseUrl = userConfig.llm.backends.openai.baseUrl.toRef() +const apiKey = userConfig.llm.backends.openai.apiKey.toRef() +const commonApiKey = userConfig.llm.apiKey.toRef() +const numCtx = userConfig.llm.backends.openai.numCtx.toRef() +const enableNumCtx = userConfig.llm.backends.openai.enableNumCtx.toRef() +const open = userConfig.settings.blocks.openaiConfig.open.toRef() + +const isCurrentEndpoint = computed(() => endpointType.value === 'openai') +const customModelOption = computed(() => { + if (!model.value || isOpenAIModel(model.value)) return undefined + return { + id: model.value, + label: `${model.value} (Custom)`, + value: model.value, + } +}) +const presetModelOptions = computed(() => { + const presetOptions = OPENAI_MODELS.map((item) => ({ + id: item.id, + label: item.name, + value: item.id, + })) + if (customModelOption.value) { + return [customModelOption.value, ...presetOptions] + } + return presetOptions +}) +const selectedPresetModel = computed({ + get: () => model.value, + set: (value?: string) => { + if (value) model.value = value + }, +}) +const modelInput = computed({ + get: () => model.value ?? '', + set: (value: string) => { + model.value = value.trim() + }, +}) + +const useOpenAI = () => { + endpointType.value = 'openai' + commonApiKey.value = apiKey.value + if (!isOpenAIModel(model.value)) { + model.value = OPENAI_MODELS[0]?.id + } + commonModel.value = model.value +} + + + diff --git a/entrypoints/settings/components/GeneralSettings/index.vue b/entrypoints/settings/components/GeneralSettings/index.vue index 45773d70..24fe1c22 100644 --- a/entrypoints/settings/components/GeneralSettings/index.vue +++ b/entrypoints/settings/components/GeneralSettings/index.vue @@ -29,6 +29,7 @@ +
{ currentLoopAssistantRawMessage.content += chunk.textDelta agentMessage.content += chunk.textDelta } - else if (chunk.type === 'reasoning') { - reasoningStart = reasoningStart || Date.now() - agentMessage.reasoningTime = reasoningStart ? Date.now() - reasoningStart : undefined - agentMessage.reasoning = (agentMessage.reasoning || '') + chunk.textDelta + else if ((chunk as { type: string }).type === 'reasoning' || (chunk as { type: string }).type === 'reasoning-delta') { + const reasoningChunk = chunk as { textDelta?: string } + if (reasoningChunk.textDelta) { + reasoningStart = reasoningStart || Date.now() + agentMessage.reasoningTime = reasoningStart ? Date.now() - reasoningStart : undefined + agentMessage.reasoning = (agentMessage.reasoning || '') + reasoningChunk.textDelta + } } else if (chunk.type === 'tool-call') { this.log.debug('Tool call received', chunk) @@ -418,7 +421,7 @@ export class Agent { const { t } = await useGlobalI18n() const errorMsg = await agentMessageManager.convertToAssistantMessage() errorMsg.isError = true - const endpointTypeName = error.endpointType === 'ollama' ? 'Ollama' : error.endpointType === 'lm-studio' ? 'LM Studio' : 'Gemini' + const endpointTypeName = error.endpointType === 'ollama' ? 'Ollama' : error.endpointType === 'lm-studio' ? 'LM Studio' : error.endpointType === 'gemini' ? 'Gemini' : 'OpenAI' errorMsg.content = t('errors.model_not_found', { endpointType: endpointTypeName }) // unresolvable error, break the loop return false @@ -427,7 +430,7 @@ export class Agent { const { t } = await useGlobalI18n() const errorMsg = await agentMessageManager.convertToAssistantMessage() errorMsg.isError = true - const endpointTypeName = error.endpointType === 'ollama' ? 'Ollama' : error.endpointType === 'lm-studio' ? 'LM Studio' : 'Gemini' + const endpointTypeName = error.endpointType === 'ollama' ? 'Ollama' : error.endpointType === 'lm-studio' ? 'LM Studio' : error.endpointType === 'gemini' ? 'Gemini' : 'OpenAI' errorMsg.content = t('errors.model_request_error', { endpointType: endpointTypeName }) return false } diff --git a/entrypoints/sidepanel/utils/chat/chat.ts b/entrypoints/sidepanel/utils/chat/chat.ts index 599eea30..0c48fed0 100644 --- a/entrypoints/sidepanel/utils/chat/chat.ts +++ b/entrypoints/sidepanel/utils/chat/chat.ts @@ -160,8 +160,12 @@ export class ReactiveHistoryManager extends EventEmitter { async appendAssistantMessage(content: string = '') { const userConfig = await getUserConfig() - const model = this.temporaryModelOverride?.model ?? userConfig.llm.model.get() const endpointType = this.temporaryModelOverride?.endpointType ?? userConfig.llm.endpointType.get() + const model = this.temporaryModelOverride?.model ?? (endpointType === 'gemini' + ? userConfig.llm.backends.gemini.model.get() || userConfig.llm.model.get() + : endpointType === 'openai' + ? userConfig.llm.backends.openai.model.get() || userConfig.llm.model.get() + : userConfig.llm.model.get()) this.history.value.push({ id: this.generateId(), @@ -179,8 +183,12 @@ export class ReactiveHistoryManager extends EventEmitter { async appendAgentMessage(content: string = '') { const userConfig = await getUserConfig() - const model = this.temporaryModelOverride?.model ?? userConfig.llm.model.get() const endpointType = this.temporaryModelOverride?.endpointType ?? userConfig.llm.endpointType.get() + const model = this.temporaryModelOverride?.model ?? (endpointType === 'gemini' + ? userConfig.llm.backends.gemini.model.get() || userConfig.llm.model.get() + : endpointType === 'openai' + ? userConfig.llm.backends.openai.model.get() || userConfig.llm.model.get() + : userConfig.llm.model.get()) this.history.value.push({ id: this.generateId(), diff --git a/entrypoints/sidepanel/utils/llm.ts b/entrypoints/sidepanel/utils/llm.ts index ce039d2d..d3b621a8 100644 --- a/entrypoints/sidepanel/utils/llm.ts +++ b/entrypoints/sidepanel/utils/llm.ts @@ -23,11 +23,21 @@ interface ExtraOptions { timeout?: number } +const resolveModelForEndpoint = (userConfig: Awaited>, endpointType: LLMEndpointType): string | undefined => { + if (endpointType === 'gemini') { + return userConfig.llm.backends.gemini.model.get() || userConfig.llm.model.get() + } + if (endpointType === 'openai') { + return userConfig.llm.backends.openai.model.get() || userConfig.llm.model.get() + } + return userConfig.llm.model.get() +} + export async function* streamTextInBackground(options: Parameters[0] & ExtraOptions & { temporaryModelOverride?: { model: string, endpointType: string } | null }) { const { abortSignal, timeout = DEFAULT_PENDING_TIMEOUT, temporaryModelOverride, ...restOptions } = options const userConfig = await getUserConfig() - const modelId = temporaryModelOverride?.model ?? userConfig.llm.model.get() const endpointType = (temporaryModelOverride?.endpointType as LLMEndpointType | undefined) ?? userConfig.llm.endpointType.get() + const modelId = temporaryModelOverride?.model ?? resolveModelForEndpoint(userConfig, endpointType) const reasoningPreference = userConfig.llm.reasoning.get() const computedReasoning = restOptions.autoThinking ? restOptions.reasoning @@ -52,7 +62,8 @@ export async function* streamTextInBackground(options: Parameters[0] & ExtraOptions) { const { abortSignal, timeout = DEFAULT_PENDING_TIMEOUT, ...restOptions } = options const userConfig = await getUserConfig() - const modelId = userConfig.llm.model.get() + const endpointType = userConfig.llm.endpointType.get() + const modelId = resolveModelForEndpoint(userConfig, endpointType) const reasoningPreference = userConfig.llm.reasoning.get() const computedReasoning = restOptions.autoThinking ? restOptions.reasoning @@ -77,7 +88,8 @@ export async function generateObjectInBackground(options: const { promise: abortPromise, reject } = Promise.withResolvers>>>() const { abortSignal, timeout = DEFAULT_PENDING_TIMEOUT, ...restOptions } = options const userConfig = await getUserConfig() - const modelId = userConfig.llm.model.get() + const endpointType = userConfig.llm.endpointType.get() + const modelId = resolveModelForEndpoint(userConfig, endpointType) const reasoningPreference = userConfig.llm.reasoning.get() const computedReasoning = restOptions.autoThinking ? restOptions.reasoning diff --git a/types/scroll-targets.ts b/types/scroll-targets.ts index fa402261..e55e69fb 100644 --- a/types/scroll-targets.ts +++ b/types/scroll-targets.ts @@ -1 +1 @@ -export type SettingsScrollTarget = 'quick-actions-block' | 'model-download-section' | 'ollama-server-address-section' | 'lm-studio-server-address-section' | 'gemini-api-config-section' +export type SettingsScrollTarget = 'quick-actions-block' | 'model-download-section' | 'ollama-server-address-section' | 'lm-studio-server-address-section' | 'gemini-api-config-section' | 'openai-api-config-section' diff --git a/utils/llm/models.ts b/utils/llm/models.ts index b59de6ab..41e2841c 100644 --- a/utils/llm/models.ts +++ b/utils/llm/models.ts @@ -11,6 +11,7 @@ import { GEMINI_MODELS, isGeminiModel } from './gemini' import { loadModel as loadLMStudioModel } from './lm-studio' import { middlewares } from './middlewares' import { checkModelSupportThinking } from './ollama' +import { isOpenAIModel, OPENAI_MODELS } from './openai' import { LMStudioChatLanguageModel } from './providers/lm-studio/chat-language-model' import { createOllama } from './providers/ollama' import { WebLLMChatLanguageModel } from './providers/web-llm/openai-compatible-chat-language-model' @@ -22,15 +23,27 @@ export async function getModelUserConfig(overrides?: { model?: string, endpointT logger.debug('Detected override model', { overrides }) const userConfig = await getUserConfig() const endpointType = overrides?.endpointType ?? userConfig.llm.endpointType.get() - const model = overrides?.model ?? userConfig.llm.model.get() + const model = overrides?.model ?? ( + endpointType === 'gemini' + ? userConfig.llm.backends.gemini.model.get() || userConfig.llm.model.get() + : endpointType === 'openai' + ? userConfig.llm.backends.openai.model.get() || userConfig.llm.model.get() + : userConfig.llm.model.get() + ) const backendKey = endpointType === 'lm-studio' ? 'lmStudio' : endpointType === 'gemini' ? 'gemini' - : 'ollama' + : endpointType === 'openai' + ? 'openai' + : 'ollama' const baseUrl = userConfig.llm.backends[backendKey].baseUrl.get() - const apiKey = userConfig.llm.apiKey.get() + const apiKey = endpointType === 'gemini' + ? userConfig.llm.backends.gemini.apiKey.get() || userConfig.llm.apiKey.get() + : endpointType === 'openai' + ? userConfig.llm.backends.openai.apiKey.get() || userConfig.llm.apiKey.get() + : userConfig.llm.apiKey.get() const numCtx = userConfig.llm.backends[backendKey].numCtx.get() const enableNumCtx = userConfig.llm.backends[backendKey].enableNumCtx.get() const reasoningPreference = userConfig.llm.reasoning.get() @@ -133,6 +146,15 @@ export async function getModel(options: { }) model = gemini.chatModel(options.model) } + else if (endpointType === 'openai') { + const normalizedBaseUrl = options.baseUrl.endsWith('/') ? options.baseUrl.slice(0, -1) : options.baseUrl + const openai = createOpenAICompatible({ + name: 'openai', + baseURL: normalizedBaseUrl, + apiKey: options.apiKey, + }) + model = openai.chatModel(options.model) + } else { throw new Error('Unsupported endpoint type ' + endpointType) } @@ -142,7 +164,7 @@ export async function getModel(options: { }) } -export type LLMEndpointType = 'ollama' | 'lm-studio' | 'web-llm' | 'gemini' +export type LLMEndpointType = 'ollama' | 'lm-studio' | 'web-llm' | 'gemini' | 'openai' export function parseErrorMessageFromChunk(error: unknown): string | null { if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { @@ -162,3 +184,9 @@ export function getGeminiModels() { } export { isGeminiModel } + +export function getOpenAIModels() { + return OPENAI_MODELS +} + +export { isOpenAIModel } diff --git a/utils/llm/openai.ts b/utils/llm/openai.ts new file mode 100644 index 00000000..f4652e90 --- /dev/null +++ b/utils/llm/openai.ts @@ -0,0 +1,23 @@ +export const OPENAI_MODELS = [ + { + id: 'gpt-4.1', + name: 'GPT-4.1', + }, + { + id: 'gpt-4.1-mini', + name: 'GPT-4.1 Mini', + }, + { + id: 'gpt-4o', + name: 'GPT-4o', + }, + { + id: 'o4-mini', + name: 'o4-mini', + }, +] as const + +export function isOpenAIModel(modelId: string | undefined | null): boolean { + if (!modelId) return false + return OPENAI_MODELS.some((model) => model.id === modelId) +} diff --git a/utils/pinia-store/store.ts b/utils/pinia-store/store.ts index 0b21e8e9..5ddadba9 100644 --- a/utils/pinia-store/store.ts +++ b/utils/pinia-store/store.ts @@ -4,6 +4,7 @@ import { computed, ref } from 'vue' import { LMStudioModelInfo } from '@/types/lm-studio-models' import { OllamaModelInfo } from '@/types/ollama-models' import { GEMINI_MODELS, isGeminiModel } from '@/utils/llm/gemini' +import { isOpenAIModel, OPENAI_MODELS } from '@/utils/llm/openai' import { logger } from '@/utils/logger' import { c2bRpc, s2bRpc, settings2bRpc } from '@/utils/rpc' @@ -20,6 +21,40 @@ const rpc = forRuntimes({ }) export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => { + const remoteCustomModelList = ref>([]) + + const updateRemoteCustomModelList = async () => { + const userConfig = await getUserConfig() + const pairs: Array<{ backend: 'gemini' | 'openai', model: string }> = [] + + const llmEndpointType = userConfig.llm.endpointType.get() + const llmModel = userConfig.llm.model.get() + if ((llmEndpointType === 'gemini' || llmEndpointType === 'openai') && llmModel) { + pairs.push({ backend: llmEndpointType, model: llmModel }) + } + + const translationEndpointType = userConfig.translation.endpointType.get() + const translationModel = userConfig.translation.model.get() + if ((translationEndpointType === 'gemini' || translationEndpointType === 'openai') && translationModel) { + pairs.push({ backend: translationEndpointType, model: translationModel }) + } + + const unique = new Map() + for (const pair of pairs) { + const isPreset = pair.backend === 'gemini' ? isGeminiModel(pair.model) : isOpenAIModel(pair.model) + if (isPreset) continue + const key = `${pair.backend}#${pair.model}` + unique.set(key, { + backend: pair.backend, + model: pair.model, + name: `${pair.model} (Custom)`, + }) + } + + remoteCustomModelList.value = [...unique.values()] + return remoteCustomModelList.value + } + // Ollama model list and connection status const ollamaModelList = ref([]) const ollamaModelListUpdating = ref(false) @@ -136,6 +171,7 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => } else { if (endpointType === 'gemini') return isGeminiModel(currentModel) + if (endpointType === 'openai') return isOpenAIModel(currentModel) return false } } @@ -169,6 +205,12 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => model: m.id, name: m.name, })), + ...OPENAI_MODELS.map((m) => ({ + backend: 'openai' as const, + model: m.id, + name: m.name, + })), + ...remoteCustomModelList.value, ] }) @@ -211,7 +253,8 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => else { status = 'backend-unavailable' } } else if (endpointType === 'gemini') { - if (isGeminiModel(commonModelConfig.get())) { + const currentModel = commonModelConfig.get() + if (currentModel) { status = 'ok' } else if (GEMINI_MODELS.length > 0) { @@ -222,6 +265,20 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => status = 'no-model' } } + else if (endpointType === 'openai') { + const currentModel = commonModelConfig.get() + if (currentModel) { + status = 'ok' + } + else if (OPENAI_MODELS.length > 0) { + commonModelConfig.set(OPENAI_MODELS[0].id) + status = 'ok' + } + else { + status = 'no-model' + } + } + await updateRemoteCustomModelList() return { modelList, commonModel: commonModelConfig.get(), status, endpointType } } @@ -231,6 +288,7 @@ export const useLLMBackendStatusStore = defineStore('llm-backend-status', () => // all available models when switching between backends in ModelSelector // WebLLM doesn't need updating as it uses static SUPPORTED_MODELS await Promise.allSettled([updateOllamaModelList(), updateLMStudioModelList()]) + await updateRemoteCustomModelList() return modelList.value } diff --git a/utils/rpc/background-fns.ts b/utils/rpc/background-fns.ts index ae6d7b29..5454994e 100644 --- a/utils/rpc/background-fns.ts +++ b/utils/rpc/background-fns.ts @@ -112,6 +112,14 @@ const normalizeError = (_error: unknown, endpointType?: LLMEndpointType) => { return error } +const resolveTemperatureForEndpoint = (endpointType: LLMEndpointType, temperature?: number) => { + // Some OpenAI-compatible models reject temperature 0 and only accept default temperature 1. + if (endpointType === 'openai') { + return temperature === undefined || temperature === 0 ? 1 : temperature + } + return temperature +} + const streamText = async (options: Pick & ExtraGenerateOptionsWithTools) => { const abortController = new AbortController() const portName = `streamText-${Date.now().toString(32)}` @@ -127,6 +135,7 @@ const streamText = async (options: Pick & ExtraGenerateOptionsWithTools) => { try { + const userConfig = await getModelUserConfig({ model: options.modelId, endpointType: options.endpointType }) + const temperature = resolveTemperatureForEndpoint(userConfig.endpointType) const response = originalGenerateText({ - model: await getModel({ ...(await getModelUserConfig({ model: options.modelId, endpointType: options.endpointType })), ...generateExtraModelOptions(options) }), + model: await getModel({ ...userConfig, ...generateExtraModelOptions(options) }), messages: options.messages, prompt: options.prompt, system: options.system, tools: PromptBasedTool.createFakeAnyTools(), + temperature, maxTokens: options.maxTokens, experimental_activeTools: [], }) @@ -195,13 +208,15 @@ const generateText = async (options: Pick Date: Sat, 7 Mar 2026 02:26:16 +0800 Subject: [PATCH 03/11] update models --- .../Blocks/GeminiConfiguration.vue | 2 +- .../Blocks/OpenAIConfiguration.vue | 2 +- utils/llm/gemini.ts | 8 ++++---- utils/llm/openai.ts | 16 ++++++++-------- utils/user-config/index.ts | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue index fc908173..16fa6b41 100644 --- a/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue +++ b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue @@ -143,7 +143,7 @@ const useGemini = () => {
diff --git a/entrypoints/settings/components/GeneralSettings/Blocks/OpenAIConfiguration.vue b/entrypoints/settings/components/GeneralSettings/Blocks/OpenAIConfiguration.vue index 2316719c..7a178dfb 100644 --- a/entrypoints/settings/components/GeneralSettings/Blocks/OpenAIConfiguration.vue +++ b/entrypoints/settings/components/GeneralSettings/Blocks/OpenAIConfiguration.vue @@ -143,7 +143,7 @@ const useOpenAI = () => {
diff --git a/utils/llm/gemini.ts b/utils/llm/gemini.ts index 55f97a6c..4f8a9c14 100644 --- a/utils/llm/gemini.ts +++ b/utils/llm/gemini.ts @@ -1,15 +1,15 @@ export const GEMINI_MODELS = [ { - id: 'gemini-2.5-pro', - name: 'Gemini 2.5 Pro', + id: 'gemini-flash-latest', + name: 'Gemini Flash Latest', }, { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', }, { - id: 'gemini-2.0-flash', - name: 'Gemini 2.0 Flash', + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', }, ] as const diff --git a/utils/llm/openai.ts b/utils/llm/openai.ts index f4652e90..83f5af80 100644 --- a/utils/llm/openai.ts +++ b/utils/llm/openai.ts @@ -1,19 +1,19 @@ export const OPENAI_MODELS = [ { - id: 'gpt-4.1', - name: 'GPT-4.1', + id: 'gpt-5.4', + name: 'GPT-5.4', }, { - id: 'gpt-4.1-mini', - name: 'GPT-4.1 Mini', + id: 'gpt-5-mini', + name: 'GPT-5 Mini', }, { - id: 'gpt-4o', - name: 'GPT-4o', + id: 'gpt-5', + name: 'GPT-5', }, { - id: 'o4-mini', - name: 'o4-mini', + id: 'gpt-5-nano', + name: 'GPT-5 Nano', }, ] as const diff --git a/utils/user-config/index.ts b/utils/user-config/index.ts index 6413a1f0..3b2f57fa 100644 --- a/utils/user-config/index.ts +++ b/utils/user-config/index.ts @@ -121,14 +121,14 @@ export async function _getUserConfig() { }, gemini: { apiKey: await new Config('llm.backends.gemini.apiKey').default('').build(), - model: await new Config('llm.backends.gemini.model').default('gemini-2.5-pro').build(), + model: await new Config('llm.backends.gemini.model').default('gemini-flash-latest').build(), numCtx: await new Config('llm.backends.gemini.numCtx').default(1024 * 8).build(), enableNumCtx: await new Config('llm.backends.gemini.enableNumCtx').default(false).build(), baseUrl: await new Config('llm.backends.gemini.baseUrl').default('https://generativelanguage.googleapis.com/v1beta/openai').build(), }, openai: { apiKey: await new Config('llm.backends.openai.apiKey').default('').build(), - model: await new Config('llm.backends.openai.model').default('gpt-4.1').build(), + model: await new Config('llm.backends.openai.model').default('gpt-5.4').build(), numCtx: await new Config('llm.backends.openai.numCtx').default(1024 * 8).build(), enableNumCtx: await new Config('llm.backends.openai.enableNumCtx').default(false).build(), baseUrl: await new Config('llm.backends.openai.baseUrl').default('https://api.openai.com/v1').build(), From 3a6bc299f055ceaa19cef8062ba94b867ab793db Mon Sep 17 00:00:00 2001 From: Bowen Date: Sat, 7 Mar 2026 02:37:52 +0800 Subject: [PATCH 04/11] update logo --- assets/icons/model-logo-gemini.svg | 4 ++++ assets/icons/model-logo-openai.svg | 12 ++++++++++++ .../Blocks/GeminiConfiguration.vue | 11 +++++++++++ .../Blocks/OpenAIConfiguration.vue | 11 +++++++++++ utils/llm/model-logos.ts | 18 ++++++++++++++++++ 5 files changed, 56 insertions(+) create mode 100644 assets/icons/model-logo-gemini.svg create mode 100644 assets/icons/model-logo-openai.svg diff --git a/assets/icons/model-logo-gemini.svg b/assets/icons/model-logo-gemini.svg new file mode 100644 index 00000000..9719b3cd --- /dev/null +++ b/assets/icons/model-logo-gemini.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/model-logo-openai.svg b/assets/icons/model-logo-openai.svg new file mode 100644 index 00000000..2f6e473c --- /dev/null +++ b/assets/icons/model-logo-openai.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue index 16fa6b41..4730d2a5 100644 --- a/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue +++ b/entrypoints/settings/components/GeneralSettings/Blocks/GeminiConfiguration.vue @@ -1,6 +1,7 @@