diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 7e6d5e7fc..e9e416045 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -494,6 +494,17 @@ pages: placeholder: Key value: placeholder: Value + thinking-mode: + label: Thinking Mode + description: > + Controls Ollama thinking behavior. GPT-OSS only supports low/medium/high levels. + options: + auto: Auto (provider default) + disable: Disable + enable: Enable + low: Low + medium: Medium + high: High validators: actions: validate: Validate diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index b561ed673..4746497df 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -470,6 +470,17 @@ pages: placeholder: 请求头名字 value: placeholder: 请求头数值 + thinking-mode: + label: 思考模式 + description: > + 控制 Ollama 的思考行为。GPT-OSS 仅支持 low/medium/high 级别。 + options: + auto: 自动(服务默认) + disable: 关闭 + enable: 开启 + low: 低 + medium: 中 + high: 高 validators: actions: validate: 验证 diff --git a/packages/stage-pages/src/pages/settings/providers/chat/ollama.vue b/packages/stage-pages/src/pages/settings/providers/chat/ollama.vue index 958b2a713..3a800296d 100644 --- a/packages/stage-pages/src/pages/settings/providers/chat/ollama.vue +++ b/packages/stage-pages/src/pages/settings/providers/chat/ollama.vue @@ -11,7 +11,7 @@ import { } from '@proj-airi/stage-ui/components' import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' -import { FieldKeyValues } from '@proj-airi/ui' +import { FieldKeyValues, FieldSelect } from '@proj-airi/ui' import { storeToRefs } from 'pinia' import { computed, onMounted, ref, watch } from 'vue' @@ -42,6 +42,14 @@ const { } = useProviderValidation(providerId) const headers = ref<{ key: string, value: string }[]>(Object.entries(providers.value[providerId]?.headers || {}).map(([key, value]) => ({ key, value } as { key: string, value: string })) || [{ key: '', value: '' }]) +const thinkingMode = computed({ + get: () => providers.value[providerId]?.thinkingMode || 'auto', + set: (value: string) => { + if (!providers.value[providerId]) + providers.value[providerId] = {} + providers.value[providerId].thinkingMode = value + }, +}) function addKeyValue(headers: { key: string, value: string }[], key: string, value: string) { if (!headers) @@ -82,6 +90,7 @@ async function refetch() { try { const validationResult = await providerMetadata.value.validators.validateProviderConfig({ baseUrl: baseUrl.value, + thinkingMode: thinkingMode.value, headers: headers.value.filter(header => header.key !== '').reduce((acc, header) => { acc[header.key] = header.value return acc @@ -101,9 +110,7 @@ async function refetch() { } } -watch([baseUrl, headers], refetch, { immediate: true }) -watch(headers, refetch, { deep: true }) - +watch([baseUrl, thinkingMode, headers], refetch, { immediate: true, deep: true }) onMounted(() => { providersStore.initializeProvider(providerId) @@ -117,6 +124,10 @@ onMounted(() => { if (headers.value.length === 0) { headers.value = [{ key: '', value: '' }] } + + if (!providers.value[providerId].thinkingMode) { + providers.value[providerId].thinkingMode = 'auto' + } }) @@ -139,6 +150,20 @@ onMounted(() => { + + { const label = typeof meta.labelLocalized === 'string' ? meta.labelLocalized : '' const description = typeof meta.descriptionLocalized === 'string' ? meta.descriptionLocalized : schema.description const placeholder = typeof meta.placeholderLocalized === 'string' ? meta.placeholderLocalized : '' + const options = Array.isArray(meta.options) + ? meta.options + .map((item) => { + if (!item || typeof item !== 'object') + return null + + const option = item as { label?: unknown, value?: unknown } + if (typeof option.label !== 'string') + return null + + if (typeof option.value !== 'string' && typeof option.value !== 'number') + return null + + return { + label: option.label, + value: option.value, + } + }) + .filter((item): item is { label: string, value: string | number } => item !== null) + : undefined return { key, schema, section, type, + options, label, description, placeholder, @@ -434,6 +455,14 @@ function handleDeleteProvider() { :placeholder="field.placeholder" :required="field.required" /> + -
+
diff --git a/packages/stage-ui/src/libs/providers/providers/ollama/index.test.ts b/packages/stage-ui/src/libs/providers/providers/ollama/index.test.ts new file mode 100644 index 000000000..d724bfe86 --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/ollama/index.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { providerOllama, resolveOllamaThink } from './index' + +describe('providerOllama.resolveOllamaThink', () => { + it('should return undefined for auto mode', () => { + expect(resolveOllamaThink('qwen3:8b', 'auto')).toBeUndefined() + }) + + it('should map disable/enable to booleans for non gpt-oss models', () => { + expect(resolveOllamaThink('qwen3:8b', 'disable')).toBe(false) + expect(resolveOllamaThink('qwen3:8b', 'enable')).toBe(true) + }) + + it('should map disable/enable to levels for gpt-oss models', () => { + expect(resolveOllamaThink('gpt-oss:20b', 'disable')).toBe('low') + expect(resolveOllamaThink('gpt-oss:20b', 'enable')).toBe('medium') + }) + + it('should pass level modes through unchanged', () => { + expect(resolveOllamaThink('qwen3:8b', 'low')).toBe('low') + expect(resolveOllamaThink('qwen3:8b', 'medium')).toBe('medium') + expect(resolveOllamaThink('qwen3:8b', 'high')).toBe('high') + }) + + it('should fallback invalid values to auto mode', () => { + expect(resolveOllamaThink('qwen3:8b', 'invalid')).toBeUndefined() + }) +}) + +describe('providerOllama.createProvider chat options', () => { + it('should not set think when thinkingMode is auto', () => { + const provider = providerOllama.createProvider({ + baseUrl: 'http://localhost:11434/v1/', + thinkingMode: 'auto', + }) as any + + const chatOptions = provider.chat('qwen3:8b') as Record + expect('think' in chatOptions).toBe(false) + }) + + it('should set think=false for non gpt-oss when thinkingMode is disable', () => { + const provider = providerOllama.createProvider({ + baseUrl: 'http://localhost:11434/v1/', + thinkingMode: 'disable', + }) as any + + const chatOptions = provider.chat('qwen3:8b') as Record + expect(chatOptions.think).toBe(false) + }) + + it('should set think=medium for gpt-oss when thinkingMode is enable', () => { + const provider = providerOllama.createProvider({ + baseUrl: 'http://localhost:11434/v1/', + thinkingMode: 'enable', + }) as any + + const chatOptions = provider.chat('gpt-oss:20b') as Record + expect(chatOptions.think).toBe('medium') + }) + + it('should set think=low for gpt-oss when thinkingMode is disable', () => { + const provider = providerOllama.createProvider({ + baseUrl: 'http://localhost:11434/v1/', + thinkingMode: 'disable', + }) as any + + const chatOptions = provider.chat('gpt-oss:20b') as Record + expect(chatOptions.think).toBe('low') + }) +}) diff --git a/packages/stage-ui/src/libs/providers/providers/ollama/index.ts b/packages/stage-ui/src/libs/providers/providers/ollama/index.ts index 4fbe0c54a..44a72b38e 100644 --- a/packages/stage-ui/src/libs/providers/providers/ollama/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/ollama/index.ts @@ -4,7 +4,61 @@ import { z } from 'zod' import { createOpenAICompatibleValidators } from '../../validators' import { defineProvider } from '../registry' -export const providerOllama = defineProvider({ +type OllamaThinkValue = boolean | 'high' | 'low' | 'medium' +type OllamaThinkingMode = 'auto' | 'disable' | 'enable' | 'high' | 'low' | 'medium' + +const ollamaConfigSchema = z.object({ + baseUrl: z.string() + .default('http://localhost:11434/v1/'), + thinkingMode: z.enum(['auto', 'disable', 'enable', 'low', 'medium', 'high']) + .default('auto'), + headers: z.record(z.string(), z.string()) + .optional(), +}) + +type OllamaConfig = z.input + +function isGptOssModel(model: string): boolean { + return model.toLowerCase().includes('gpt-oss') +} + +function normalizeOllamaThinkingMode(value: unknown): OllamaThinkingMode { + switch (value) { + case 'auto': + case 'disable': + case 'enable': + case 'high': + case 'low': + case 'medium': + return value + default: + return 'auto' + } +} + +export function resolveOllamaThink(model: string, modeRaw: unknown): OllamaThinkValue | undefined { + const mode = normalizeOllamaThinkingMode(modeRaw) + const isGptOss = isGptOssModel(model) + + switch (mode) { + case 'auto': + return undefined + case 'disable': + // NOTICE: GPT-OSS ignores boolean `think`, so "disable" degrades to `low`. + return isGptOss ? 'low' : false + case 'enable': + // NOTICE: GPT-OSS requires levels; map generic "enable" to medium effort. + return isGptOss ? 'medium' : true + case 'low': + case 'medium': + case 'high': + return mode + default: + return undefined + } +} + +export const providerOllama = defineProvider({ id: 'ollama', order: 2, name: 'Ollama', @@ -14,16 +68,47 @@ export const providerOllama = defineProvider({ tasks: ['chat'], icon: 'i-lobe-icons:ollama', - createProviderConfig: ({ t }) => z.object({ - baseUrl: z.string() - .default('http://localhost:11434/v1/') + createProviderConfig: ({ t }) => ollamaConfigSchema.extend({ + baseUrl: ollamaConfigSchema.shape.baseUrl .meta({ labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'), descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'), placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'), }), - headers: z.record(z.string(), z.string()) - .optional() + thinkingMode: ollamaConfigSchema.shape.thinkingMode + .meta({ + labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.label'), + descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.description'), + section: 'advanced', + type: 'select', + options: [ + { + label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.auto'), + value: 'auto', + }, + { + label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.disable'), + value: 'disable', + }, + { + label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.enable'), + value: 'enable', + }, + { + label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.low'), + value: 'low', + }, + { + label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.medium'), + value: 'medium', + }, + { + label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.high'), + value: 'high', + }, + ], + }), + headers: ollamaConfigSchema.shape.headers .meta({ labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.headers.label'), descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.headers.description'), @@ -32,7 +117,20 @@ export const providerOllama = defineProvider({ }), }), createProvider(config) { - return createOllama('', config.baseUrl) + const baseProvider = createOllama('', config.baseUrl) + + return { + ...baseProvider, + chat(model: string) { + const chatOptions = baseProvider.chat(model) + const think = resolveOllamaThink(model, config.thinkingMode) + + if (think === undefined) + return chatOptions + + return { ...chatOptions, think } + }, + } }, validationRequiredWhen: () => true, validators: { diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index 9e4e82b91..8adb6efb5 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -44,6 +44,7 @@ function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProv async function streamFrom(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) { const headers = options?.headers + const chatConfig = chatProvider.chat(model) const sanitized = sanitizeMessages(messages as unknown[]) const resolveTools = async () => { @@ -97,7 +98,7 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M try { streamText({ - ...chatProvider.chat(model), + ...chatConfig, maxSteps: 10, messages: sanitized, headers,