Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/specs/ollama-model-selection/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Ollama 模型可选性与跨窗口同步实施计划

## 1. 关键决策

1. 真源统一到 main/config 持久层,renderer `ollamaStore` 只保留 UI 状态。
2. Ollama provider 刷新时同时采集 `listModels()` 与 `listRunningModels()` 并合并。
3. 新发现模型通过 `ensureModelStatus(..., true)` 默认启用,但不覆盖已有显式状态。
4. 本次只做 SDK 审计,不升级 `ollama` 依赖版本。

## 2. main/config 设计

### 2.1 状态语义

1. 在 `ModelStatusHelper` 增加 `ensureModelStatus`。
2. 仅当状态尚未存在时写入默认值。
3. 直接写存储并更新 cache,不发送 `MODEL_STATUS_CHANGED`。

### 2.2 Ollama provider 列表构建

1. `fetchProviderModels()` 并行获取本地模型与运行中模型。
2. 使用 `model.name` 合并,优先保留本地模型主体字段。
3. 合并 `capabilities`、`model_info` 和已有缓存能力元数据。
4. 生成 `MODEL_META` 后写入 config provider models。

## 3. renderer 刷新链路

1. `ollamaStore.refreshOllamaModels(providerId)` 先更新设置页本地/运行中列表。
2. 随后调用 `llmP.refreshModels(providerId)` 让 main 重建持久化目录。
3. 当前窗口再调用 `modelStore.refreshProviderModels(providerId)` 收敛显示。
4. pull 成功事件复用同一刷新链路。

## 4. 回归点

1. 删除 `modelStore.updateModelStatus()` 中对 Ollama 的提前返回,确保显式启停会落到 config。
2. 聊天侧继续依赖 `modelStore.enabledModels`,不增加临时兼容分支。

## 5. 测试策略

### Main

1. `ModelStatusHelper.ensureModelStatus` 不覆盖显式关闭状态。
2. `OllamaProvider.fetchModels()` 合并本地与运行中模型并保留能力元数据。

### Renderer

1. `ollamaStore.refreshOllamaModels()` 会调用 `llmP.refreshModels()` 与 `modelStore.refreshProviderModels()`。
2. pull 成功事件会触发同样的刷新链路。
3. `ChatStatusBar`、`ModelSelect`、`ModelChooser` 显示 Ollama chat 模型并过滤 Ollama embedding 模型。
71 changes: 71 additions & 0 deletions docs/specs/ollama-model-selection/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Ollama 模型可选性与跨窗口同步规格

## 概述

修复 Ollama 模型在设置页可见但在聊天状态栏、模型选择器等入口不可选的问题,并统一模型目录真源到 main/config 持久层。

## 背景与目标

1. 当前设置窗口通过 `ollamaStore` 的本地临时状态直接拼装可选模型,和主聊天窗口依赖的 `modelStore`/config 真源不一致。
2. 运行中的 Ollama 模型没有稳定进入持久化目录,导致跨窗口、刷新后、重启后行为不一致。
3. 新发现的本地/运行中模型需要默认可选,但必须保留用户显式关闭的结果。

## 用户故事

### US-1:聊天入口可选

作为用户,我希望在设置页刷新出 Ollama 模型后,不用重启聊天窗口就能在 `ChatStatusBar` 和其他模型选择器中选中它们。

### US-2:运行中模型可见

作为用户,我希望仅存在于“运行中模型”列表里的 Ollama 模型也能进入可选目录,而不是只认本地模型列表。

### US-3:显式关闭可保留

作为用户,我希望手动关闭某个 Ollama 模型后,后续刷新不会把它重新强制打开。

## 功能需求

### A. 真源统一

- [ ] Ollama 可选模型目录以 main/config 持久层为准。
- [ ] renderer `ollamaStore` 只维护设置页 UI 状态,不再直接改写全局模型真源。

### B. 模型合并

- [ ] Ollama provider 刷新时合并 `本地模型 ∪ 运行中模型`。
- [ ] 以 `model.name` 去重。
- [ ] 本地模型和运行中模型同名时,以本地模型为主,补齐运行中模型额外信息。
- [ ] 运行中-only 模型也进入持久化 provider model 列表。

### C. 元数据保真

- [ ] 合并结果保留 `type/contextLength/vision/functionCall/reasoning` 等关键能力字段。
- [ ] 嵌入模型继续标记为 `embedding`,聊天选择器仍应过滤掉它们。

### D. 默认启用语义

- [ ] 新发现的 Ollama 本地/运行中模型默认设为可选。
- [ ] 已有显式状态时不得覆盖,尤其是用户已关闭的模型。
- [ ] 该默认启用写入不发送逐模型状态变更事件。

### E. 刷新链路

- [ ] 设置页手动刷新、初始化、pull 成功后都走同一条刷新链路:
1. 拉取本地/运行中模型更新设置页 UI。
2. 调 main `refreshModels(providerId)` 重建持久化目录。
3. 当前窗口刷新 `modelStore.refreshProviderModels(providerId)`。

## 非目标

1. 本次不升级 `ollama` SDK。
2. 不改 Ollama 设置页“运行中的模型 / 本地模型”分区 UI。
3. 不新增用户可见配置项。

## 验收标准

- [ ] 设置页刷新 Ollama 后,聊天窗口模型选择器立即能看到新的 chat 模型。
- [ ] 运行中-only 的 Ollama 模型能被选中发起会话。
- [ ] Ollama embedding 模型不会出现在聊天模型列表中。
- [ ] 用户关闭某个 Ollama 模型后,刷新不会重新启用它。
- [ ] `package.json` 中 `ollama` 版本保持不变。
11 changes: 11 additions & 0 deletions docs/specs/ollama-model-selection/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Ollama 模型可选性与跨窗口同步任务拆分

- [x] 为 config/model status 增加 `ensureModelStatus` 语义。
- [x] 调整 Ollama provider 的模型抓取逻辑,合并本地与运行中模型。
- [x] 调整 renderer `ollamaStore`,改为 UI 状态 + 主进程刷新链路。
- [x] 移除 Ollama 模型状态更新的本地短路逻辑,保证显式关闭可持久化。
- [x] 补充 main/store/component 回归测试。
- [x] 运行 `pnpm run format`
- [x] 运行 `pnpm run i18n`
- [x] 运行 `pnpm run lint`
- [x] 运行相关测试并确认通过
4 changes: 4 additions & 0 deletions src/main/presenter/configPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,10 @@ export class ConfigPresenter implements IConfigPresenter {
this.modelStatusHelper.setModelStatus(providerId, modelId, enabled)
}

ensureModelStatus(providerId: string, modelId: string, enabled: boolean): void {
this.modelStatusHelper.ensureModelStatus(providerId, modelId, enabled)
}

enableModel(providerId: string, modelId: string): void {
this.modelStatusHelper.enableModel(providerId, modelId)
}
Expand Down
23 changes: 23 additions & 0 deletions src/main/presenter/configPresenter/modelStatusHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export class ModelStatusHelper {
return result
}

private hasStoredStatus(statusKey: string): boolean {
const candidate = this.store as ElectronStore<any> & { has?: (key: string) => boolean }
if (typeof candidate.has === 'function') {
return candidate.has(statusKey)
}
return this.store.get(statusKey) !== undefined
}

setModelStatus(providerId: string, modelId: string, enabled: boolean): void {
const statusKey = this.getStatusKey(providerId, modelId)
this.setSetting(statusKey, enabled)
Expand All @@ -84,6 +92,21 @@ export class ModelStatusHelper {
this.setModelStatus(providerId, modelId, false)
}

ensureModelStatus(providerId: string, modelId: string, enabled: boolean): void {
const statusKey = this.getStatusKey(providerId, modelId)

if (this.cache.has(statusKey) || this.hasStoredStatus(statusKey)) {
if (!this.cache.has(statusKey)) {
const status = this.store.get(statusKey) as boolean | undefined
this.cache.set(statusKey, typeof status === 'boolean' ? status : false)
}
return
}

this.store.set(statusKey, enabled)
this.cache.set(statusKey, enabled)
}

clearModelStatusCache(): void {
this.cache.clear()
}
Expand Down
132 changes: 119 additions & 13 deletions src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
LLM_EMBEDDING_ATTRS,
IConfigPresenter
} from '@shared/presenter'
import { ModelType } from '@shared/model'
import { DEFAULT_MODEL_CONTEXT_LENGTH, DEFAULT_MODEL_MAX_TOKENS } from '@shared/modelConfigDefaults'
import { createStreamEvent } from '@shared/types/core/llm-events'
import { BaseLLMProvider, SUMMARY_TITLES_PROMPT } from '../baseProvider'
Expand Down Expand Up @@ -75,24 +76,129 @@ export class OllamaProvider extends BaseLLMProvider {
return headers
}

private mergeCapabilities(...sources: Array<string[] | undefined>): string[] {
return Array.from(new Set(sources.flatMap((source) => (Array.isArray(source) ? source : []))))
}

private mergeModelInfo(
primary?: OllamaModel['model_info'],
secondary?: OllamaModel['model_info']
): OllamaModel['model_info'] {
if (!primary && !secondary) {
return undefined
}

const mergedGeneral =
secondary?.general || primary?.general
? {
...secondary?.general,
...primary?.general
}
: undefined

const mergedVisionEmbeddingLength =
primary?.vision?.embedding_length ?? secondary?.vision?.embedding_length
const mergedVision =
typeof mergedVisionEmbeddingLength === 'number'
? {
embedding_length: mergedVisionEmbeddingLength
}
: undefined

return {
...secondary,
...primary,
...(mergedGeneral ? { general: mergedGeneral } : {}),
...(mergedVision ? { vision: mergedVision } : {})
}
}

private mergeOllamaModels(preferred: OllamaModel, secondary?: OllamaModel): OllamaModel {
if (!secondary) {
return preferred
}

return {
...secondary,
...preferred,
details: {
...secondary.details,
...preferred.details
},
model_info: this.mergeModelInfo(preferred.model_info, secondary.model_info),
capabilities: this.mergeCapabilities(preferred.capabilities, secondary.capabilities)
}
}

private resolveOllamaModelMeta(model: OllamaModel, cachedModel?: MODEL_META): MODEL_META {
const capabilitySet = new Set(
this.mergeCapabilities(
model.capabilities,
cachedModel?.type === ModelType.Embedding ? ['embedding'] : undefined,
cachedModel?.vision ? ['vision'] : undefined,
cachedModel?.functionCall ? ['tools'] : undefined,
cachedModel?.reasoning ? ['thinking'] : undefined
)
)

const resolvedType = capabilitySet.has('embedding')
? ModelType.Embedding
: (cachedModel?.type ?? ModelType.Chat)

const family = model.details?.family || cachedModel?.group || 'default'
const parameterSize = model.details?.parameter_size || ''
const description = `${parameterSize} ${family} model`.trim()

return {
id: model.name,
name: model.name,
providerId: this.provider.id,
contextLength:
model.model_info?.context_length ??
cachedModel?.contextLength ??
DEFAULT_MODEL_CONTEXT_LENGTH,
maxTokens: cachedModel?.maxTokens ?? DEFAULT_MODEL_MAX_TOKENS,
isCustom: false,
group: family,
description,
vision: capabilitySet.has('vision') || Boolean(model.model_info?.vision?.embedding_length),
functionCall: capabilitySet.has('tools'),
reasoning: capabilitySet.has('thinking'),
type: resolvedType
}
}

// Basic Provider functionality implementation
protected async fetchProviderModels(): Promise<MODEL_META[]> {
try {
console.log('Ollama service check', this.ollama, this.provider)
// Get list of locally installed Ollama models
const ollamaModels = await this.listModels()
const [localModels, runningModels] = await Promise.all([
this.listModels(),
this.listRunningModels()
])

// Convert Ollama model format to application's MODEL_META format
return ollamaModels.map((model) => ({
id: model.name,
name: model.name,
providerId: this.provider.id,
contextLength: DEFAULT_MODEL_CONTEXT_LENGTH,
maxTokens: DEFAULT_MODEL_MAX_TOKENS,
isCustom: false,
group: model.details?.family || 'default',
description: `${model.details?.parameter_size || ''} ${model.details?.family || ''} model`
}))
const cachedModels = new Map(
this.configPresenter.getProviderModels(this.provider.id).map((model) => [model.id, model])
)

const mergedModels = new Map<string, OllamaModel>()
for (const localModel of localModels) {
mergedModels.set(localModel.name, localModel)
}
for (const runningModel of runningModels) {
const existing = mergedModels.get(runningModel.name)
const merged = existing
? this.mergeOllamaModels(existing, runningModel)
: this.mergeOllamaModels(runningModel)
mergedModels.set(runningModel.name, merged)
}

const resolvedModels = Array.from(mergedModels.values()).map((model) => {
this.configPresenter.ensureModelStatus(this.provider.id, model.name, true)
return this.resolveOllamaModelMeta(model, cachedModels.get(model.name))
})

return resolvedModels
} catch (error) {
console.error('Failed to fetch Ollama models:', error)
// Fallback to aggregated Provider DB curated list for Ollama
Expand Down
5 changes: 0 additions & 5 deletions src/renderer/src/stores/modelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,11 +577,6 @@ export const useModelStore = defineStore('model', () => {
const previousState = getLocalModelEnabledState(providerId, modelId)
updateLocalModelStatus(providerId, modelId, enabled)

const provider = providerStore.providers.find((p) => p.id === providerId)
if (provider?.apiType === 'ollama') {
return
}

try {
await llmP.updateModelStatus(providerId, modelId, enabled)
await refreshProviderModels(providerId)
Expand Down
Loading
Loading