diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e051d2..ad1ac231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 +- 模型页注册的本机 Ollama / GGUF 现在会出现在对话区模型选择器里,并走本机 `127.0.0.1:11434` 调用。注册会补齐 Ollama 供应商默认值、打开服务开关、热加载对话列表;Ollama 未启动时给出可操作错误,而不是静默失败。 - 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。 - 组织页默认只显示本机 openXYOS 嵌入和顶部蓝色状态栏;「启用组织模块」「组织OS管理」在状态栏右下。互生长导入/导出、源码下载、循环时间线等收到「组织OS管理」。iframe 高度等于可见预览区,openXYOS 弹窗按 iframe 视口水平垂直居中。 - 嵌入本机 openXYOS 登录不再因 CORS 漏掉 `http://127.0.0.1:3780` 而返回笼统的「服务器内部错误」。启动环境合并 sidecar 自源;无演示种子时写入本地 demo 账号,保证隔空第一台电脑能登录。 diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts index e24b12c2..c6b72bf2 100644 --- a/dashboard/src/api/modules/localModels.ts +++ b/dashboard/src/api/modules/localModels.ts @@ -58,6 +58,7 @@ export interface LocalRuntimeResult { size?: number; source?: string; provider_name?: string; + registered?: boolean; } export interface LocalScanJob { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index caf52f49..2143c3ce 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1264,7 +1264,8 @@ "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", "provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.", - "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models." + "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.", + "local_runtime": "The local Ollama runtime is not reachable. Start Ollama from Settings → Models, then retry. If it is already running, confirm it is listening on port 11434." }, "chat": { "sharedExpert": { diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 9cbe109e..ecd480ca 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1264,7 +1264,8 @@ "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", "provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。", - "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。" + "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。", + "local_runtime": "本机 Ollama 未就绪或无法连接。请到「设置 → 模型」启动 Ollama 后再试。若已启动,请确认它在 11434 端口监听。" }, "chat": { "sharedExpert": { diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index 6d172ca7..c8c2c0f2 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -10,6 +10,7 @@ import { } from "../../../api/modules/knowledgeBases"; import type { ResolvedModel } from "../../../api/types"; import { CONNECTORS_CHANGED_EVENT } from "../../Agent/Connectors/customMcpUtils"; +import { MODELS_CHANGED_EVENT } from "../../Settings/Models/modelsChanged"; import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { useAgent } from "../../../context/AgentContext"; import { activeModelToRef } from "./useChatContextWindow"; @@ -273,9 +274,11 @@ export function useChatComposerResources( loadModels(); const onFocus = () => loadModels(); window.addEventListener("focus", onFocus); + window.addEventListener(MODELS_CHANGED_EVENT, loadModels); return () => { cancelled = true; window.removeEventListener("focus", onFocus); + window.removeEventListener(MODELS_CHANGED_EVENT, loadModels); }; }, []); diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx index 18314076..b6d3c834 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; const probe = vi.fn(); const startOllama = vi.fn(); @@ -76,9 +77,17 @@ beforeEach(() => { }); }); +function renderPanel(props: { onSaved?: () => void | Promise } = {}) { + return render( + + + , + ); +} + describe("", () => { it("shows Start Ollama when the binary exists but the API is down", async () => { - render(); + renderPanel(); await waitFor(() => expect(probe).toHaveBeenCalled()); expect(screen.getByText("models.localStartOllama")).toBeInTheDocument(); await userEvent.click(screen.getByText("models.localStartOllama")); @@ -86,16 +95,30 @@ describe("", () => { }); it("lists discovered GGUF files with a register action", async () => { - render(); + renderPanel(); await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); expect(screen.getByText("D:\\\\models\\\\tiny.gguf")).toBeInTheDocument(); expect(screen.getByText("models.localRegister")).toBeInTheDocument(); }); it("starts a local weight search", async () => { - render(); + renderPanel(); await waitFor(() => expect(probe).toHaveBeenCalled()); await userEvent.click(screen.getByText("models.localSearchModels")); await waitFor(() => expect(startScan).toHaveBeenCalled()); }); + + it("notifies the chat picker after a successful register", async () => { + const onSaved = vi.fn(); + const heard = vi.fn(); + register.mockResolvedValue({ ok: true, name: "tiny" }); + window.addEventListener("octop:models-changed", heard); + renderPanel({ onSaved }); + await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); + await userEvent.click(screen.getByText("models.localRegister")); + await waitFor(() => expect(register).toHaveBeenCalled()); + expect(heard).toHaveBeenCalled(); + expect(onSaved).toHaveBeenCalled(); + window.removeEventListener("octop:models-changed", heard); + }); }); diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index a430d65a..ddf40cfd 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -26,32 +26,10 @@ import { canPickDesktopFolder, pickDesktopFolder, } from "../../../../utils/desktopFolder"; -import { providerApi } from "../../../../api/modules/provider"; import { message } from "../../../../utils/antdMessage"; import { apiErrorMessage } from "../../../../utils/apiError"; import { CONVERSATION_LIST_PATH } from "../../../../layouts/conversationHome"; -import { isOllamaProviderRow } from "../presetUtils"; - -async function registerOllamaModel(name: string): Promise { - const providers = await providerApi.listProviders(); - const ollama = providers.find( - (row) => - row.id === "ollama" || - isOllamaProviderRow({ - name: row.name, - base_url: row.current_base_url, - api_key: row.current_api_key, - }), - ); - if (!ollama) return false; - const already = [ - ...(ollama.models ?? []), - ...(ollama.extra_models ?? []), - ].some((model) => model.id === name || model.name === name); - if (already) return true; - await providerApi.addModel(ollama.id, { id: name, name }); - return true; -} +import { notifyModelsChanged } from "../modelsChanged"; function mergeModels( ...groups: Array @@ -77,7 +55,11 @@ function canRegister(item: LocalInstalledModel): boolean { ); } -export function LocalHardwarePanel() { +export function LocalHardwarePanel({ + onSaved, +}: { + onSaved?: () => void | Promise; +} = {}) { const { t } = useTranslation(); const navigate = useNavigate(); const [probe, setProbe] = useState(null); @@ -199,18 +181,14 @@ export function LocalHardwarePanel() { await refresh(); return; } - let registered = false; - try { - registered = await registerOllamaModel(name); - } catch { - registered = false; - } + notifyModelsChanged(); + await onSaved?.(); message.success( t( - registered - ? "models.localInstallRegistered" - : "models.localInstallNeedProvider", - { name }, + result.registered === false + ? "models.localInstallNeedProvider" + : "models.localInstallRegistered", + { name: result.name || name }, ), ); await refresh(); @@ -338,6 +316,8 @@ export function LocalHardwarePanel() { showRuntimeError(result, t("models.localRegisterFailed")); return; } + notifyModelsChanged(); + await onSaved?.(); message.success(t("models.localRegisterDone", { name: result.name })); await refresh(); } catch (err) { diff --git a/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx b/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx index 378b150e..5b07ab96 100644 --- a/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx +++ b/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx @@ -21,6 +21,7 @@ import type { ProviderPreset, ProviderRow, } from "../../useProviders"; +import { MODELS_CHANGED_EVENT } from "../../modelsChanged"; import { presetLogoId } from "../../presetUtils"; import { ProviderConfigModal } from "../modals/ProviderConfigModal"; import styles from "../../index.module.less"; @@ -71,36 +72,41 @@ export function LocalServiceCard({ useEffect(() => { let cancelled = false; - void (async () => { - try { - if (isOnnx) { - const st = await onnxModelApi.getStatus(); - if (!cancelled) { - setServiceEnabled(st.enabled); - setServiceRunning(st.ready || st.enabled); - setDepsAvailable(st.deps_available !== false); - setDepsInstallFailed(false); + const refreshService = () => { + void (async () => { + try { + if (isOnnx) { + const st = await onnxModelApi.getStatus(); + if (!cancelled) { + setServiceEnabled(st.enabled); + setServiceRunning(st.ready || st.enabled); + setDepsAvailable(st.deps_available !== false); + setDepsInstallFailed(false); + } + } else { + const st = await ollamaModelApi.getService(); + if (!cancelled) { + setServiceEnabled(st.enabled); + setServiceRunning(st.running); + setDepsAvailable(true); + setDepsInstallFailed(false); + } } - } else { - const st = await ollamaModelApi.getService(); + } catch { if (!cancelled) { - setServiceEnabled(st.enabled); - setServiceRunning(st.running); - setDepsAvailable(true); - setDepsInstallFailed(false); + setServiceEnabled(false); + setServiceRunning(false); } } - } catch { - if (!cancelled) { - setServiceEnabled(false); - setServiceRunning(false); - } - } - })(); + })(); + }; + refreshService(); + window.addEventListener(MODELS_CHANGED_EVENT, refreshService); return () => { cancelled = true; + window.removeEventListener(MODELS_CHANGED_EVENT, refreshService); }; - }, [isOnnx, provider?.id]); + }, [isOnnx, provider?.id, provider?.enabled]); const ensureProvider = async (): Promise => { if (row) return row; diff --git a/dashboard/src/pages/Settings/Models/index.tsx b/dashboard/src/pages/Settings/Models/index.tsx index 41d86d1e..9abbc945 100644 --- a/dashboard/src/pages/Settings/Models/index.tsx +++ b/dashboard/src/pages/Settings/Models/index.tsx @@ -276,7 +276,7 @@ export default function ModelsPage() { - {showLocalTab ? : null} + {showLocalTab ? : null} {showPresetSection && ( <> diff --git a/dashboard/src/pages/Settings/Models/modelsChanged.ts b/dashboard/src/pages/Settings/Models/modelsChanged.ts new file mode 100644 index 00000000..bf5336e3 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/modelsChanged.ts @@ -0,0 +1,6 @@ +export const MODELS_CHANGED_EVENT = "octop:models-changed"; + +export function notifyModelsChanged(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(MODELS_CHANGED_EVENT)); +} diff --git a/dashboard/src/pages/Settings/Models/presetUtils.test.ts b/dashboard/src/pages/Settings/Models/presetUtils.test.ts new file mode 100644 index 00000000..e2e4d98c --- /dev/null +++ b/dashboard/src/pages/Settings/Models/presetUtils.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { findConfiguredProvider } from "./presetUtils"; +import type { ProviderPreset, ProviderRow } from "./useProviders"; + +const ollamaPreset = { + id: "ollama", + name: "Ollama (Local)", +} as ProviderPreset; + +function row( + partial: Partial & Pick, +): ProviderRow { + return { + id: 1, + kind: "openai", + base_url: null, + api_key: null, + models: [], + note: null, + enabled: true, + ...partial, + }; +} + +describe("findConfiguredProvider", () => { + it("matches the Ollama preset by display name or id", () => { + const named = row({ name: "Ollama (Local)" }); + expect(findConfiguredProvider(ollamaPreset, [named])).toEqual(named); + const short = row({ name: "ollama" }); + expect(findConfiguredProvider(ollamaPreset, [short])).toEqual(short); + }); + + it("matches a local Ollama row that only shares the 11434 endpoint", () => { + const local = row({ + name: "Desktop Llama", + base_url: "http://127.0.0.1:11434/v1", + api_key: "ollama", + }); + expect(findConfiguredProvider(ollamaPreset, [local])).toEqual(local); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/presetUtils.ts b/dashboard/src/pages/Settings/Models/presetUtils.ts index fdf7ca48..359b62be 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.ts @@ -65,13 +65,6 @@ export function presetLogoId(preset: ProviderPreset): string { return preset.logo_id || preset.id; } -export function findConfiguredProvider( - preset: ProviderPreset, - providers: ProviderRow[], -): ProviderRow | undefined { - return providers.find((p) => p.name === preset.name || p.name === preset.id); -} - export function groupPresets(presets: ProviderPreset[]): { grouped: PresetGroup[]; ungrouped: ProviderPreset[]; @@ -167,3 +160,20 @@ export function isLocalProviderRow(provider: { }): boolean { return isOnnxProviderRow(provider) || isOllamaProviderRow(provider); } + +export function findConfiguredProvider( + preset: ProviderPreset, + providers: ProviderRow[], +): ProviderRow | undefined { + const exact = providers.find( + (p) => p.name === preset.name || p.name === preset.id, + ); + if (exact) return exact; + if (preset.id === "ollama") { + return providers.find((p) => isOllamaProviderRow(p)); + } + if (preset.id === "onnx") { + return providers.find((p) => isOnnxProviderRow(p)); + } + return undefined; +} diff --git a/dashboard/src/utils/chatStreamError.test.ts b/dashboard/src/utils/chatStreamError.test.ts index 2faf867c..4ca2ce53 100644 --- a/dashboard/src/utils/chatStreamError.test.ts +++ b/dashboard/src/utils/chatStreamError.test.ts @@ -47,6 +47,15 @@ describe("classifyChatStreamError", () => { }); }); + it("classifies an unreachable local Ollama runtime", () => { + const msg = "Failed to connect to ollama at http://127.0.0.1:11434"; + expect(classifyChatStreamError(msg)).toBe("stream_errors.local_runtime"); + expect(chatStreamErrorAction(msg)).toEqual({ + path: "/models", + labelKey: "models.useInChat", + }); + }); + it("classifies HTTP 5xx as provider_unavailable", () => { expect(classifyChatStreamError("HTTP 503: service overloaded")).toBe( "stream_errors.provider_unavailable", diff --git a/dashboard/src/utils/chatStreamError.ts b/dashboard/src/utils/chatStreamError.ts index 9773e0a2..7b5304ca 100644 --- a/dashboard/src/utils/chatStreamError.ts +++ b/dashboard/src/utils/chatStreamError.ts @@ -12,6 +12,7 @@ const _STREAM_ERROR_KEYS = [ "stream_errors.timeout_network", "stream_errors.provider_unavailable", "stream_errors.model_call_failed", + "stream_errors.local_runtime", ] as const; export type StreamErrorKey = (typeof _STREAM_ERROR_KEYS)[number]; @@ -35,6 +36,10 @@ const STREAM_ERROR_ACTIONS: Partial> = path: "/system-settings/agent-config", labelKey: "chat.goToAgentConfig", }, + "stream_errors.local_runtime": { + path: "/models", + labelKey: "models.useInChat", + }, }; function normalizeMessage(message: string): string { @@ -126,6 +131,18 @@ export function classifyChatStreamError( return "stream_errors.recursion_limit"; } + if ( + (lower.includes("11434") || lower.includes("ollama")) && + (lower.includes("connect") || + lower.includes("refused") || + lower.includes("unreachable") || + lower.includes("not running") || + lower.includes("connection") || + lower.includes("errno 111")) + ) { + return "stream_errors.local_runtime"; + } + if ( compact.includes("internalservererror") || lower.includes("bad gateway") || diff --git a/src/octop/api/routers/local_models.py b/src/octop/api/routers/local_models.py index 761d9092..16c94ce9 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import json import logging import subprocess from typing import Any @@ -13,14 +12,18 @@ from octop.api.deps import current_user, get_server, require_permission from octop.infra.agents.providers.local_probe import probe_local_models -from octop.infra.agents.providers.local_register import load_registered, register_local_weight +from octop.infra.agents.providers.local_register import ( + ensure_ollama_service_flag, + load_registered, + register_local_weight, + upsert_ollama_model, +) from octop.infra.agents.providers.local_scan import ( cancel_scan_job, get_scan_job, latest_scan_job, start_scan_job, ) -from octop.infra.agents.providers.model_flags import is_ollama_local_provider from octop.infra.agents.providers.ollama_install import ensure_ollama_runtime from octop.infra.errors import ErrorCode, OctopError from octop.infra.server import OctopServer @@ -137,37 +140,26 @@ async def local_models_ensure_deps( return await asyncio.to_thread(ensure_ollama_runtime, install=body.install) -def _register_ollama_model(server: OctopServer, name: str) -> bool: +def _register_installed_ollama(server: OctopServer, name: str) -> str: + """Persist *name* on the shared Ollama provider and mark the runtime on.""" services = getattr(server, "services", None) repo = getattr(services, "provider_repo", None) if services is not None else None if repo is None: - return False - model = {"id": name, "name": name, "enabled": True, "input": ["text"]} - row = None - for candidate in repo.list_all(): - if is_ollama_local_provider( - candidate.name, - provider_api_key=candidate.api_key, - provider_base_url=candidate.base_url, - ): - row = candidate - break - if row is None: - repo.create( - name="ollama", - kind="ollama", - base_url="http://127.0.0.1:11434/v1", - api_key="ollama", - models_json=json.dumps([model]), - note="Registered by one-click local model install", - ) - return True - models = row.get_models() - if any(str(item.get("id") or "") == name for item in models if isinstance(item, dict)): - return False - models.append(model) - repo.update(row.id, models_json=json.dumps(models)) - return True + raise OSError("provider store is not available") + provider_name = upsert_ollama_model(repo, name, name) + settings = getattr(services, "settings_repo", None) + if settings is not None: + ensure_ollama_service_flag(settings) + return provider_name + + +async def _reload_after_register(server: OctopServer, provider_name: str, model_name: str) -> None: + if server.app_runtime is None: + return + try: + await server.app_runtime.agent_registry.on_provider_changed(provider_name=provider_name) + except Exception as exc: + logger.warning("Registered %s but provider reload failed: %s", model_name, exc) @router.post("/install", summary="Download and register a recommended Ollama model") @@ -187,16 +179,19 @@ async def local_models_install( info = await asyncio.to_thread(OllamaModelManager.pull_model, body.name) except Exception as exc: raise register_failure(exc) from exc + model_name = info.name or body.name try: - registered = _register_ollama_model(server, info.name or body.name) + provider_name = _register_installed_ollama(server, model_name) except Exception as exc: raise register_failure(exc) from exc + await _reload_after_register(server, provider_name, model_name) return { "ok": True, - "name": info.name, + "name": model_name, "size": info.size, "source": "ollama", - "registered": registered, + "registered": True, + "provider_name": provider_name, } @@ -259,15 +254,10 @@ async def local_models_register( ) except _REGISTER_FAIL_TYPES as exc: raise register_failure(exc) from exc - if server.app_runtime is not None and result.get("ok"): - try: - await server.app_runtime.agent_registry.on_provider_changed( - provider_name=str(result.get("provider_name") or "Ollama (Local)") - ) - except Exception as exc: - logger.warning( - "Registered %s but provider reload failed: %s", - result.get("name"), - exc, - ) + if result.get("ok"): + await _reload_after_register( + server, + str(result.get("provider_name") or "Ollama (Local)"), + str(result.get("name") or body.name or ""), + ) return result diff --git a/src/octop/api/routers/ollama_models.py b/src/octop/api/routers/ollama_models.py index b987a804..4d42e2c8 100644 --- a/src/octop/api/routers/ollama_models.py +++ b/src/octop/api/routers/ollama_models.py @@ -24,6 +24,7 @@ get_tasks, update_status, ) +from octop.infra.agents.providers.model_flags import OLLAMA_SERVICE_SETTINGS_KEY logger = logging.getLogger(__name__) @@ -233,9 +234,6 @@ async def delete_ollama_model( return {"status": "deleted", "name": name} -_SETTINGS_KEY_OLLAMA_SERVICE = "ollama_service_enabled" - - class OllamaServiceBody(BaseModel): enabled: bool = Field(..., description="Whether Octop should keep the Ollama service running") @@ -246,7 +244,7 @@ class OllamaServiceStatus(BaseModel): def _ollama_service_enabled(server: Any) -> bool: - raw = server.services.settings_repo.get(_SETTINGS_KEY_OLLAMA_SERVICE) + raw = server.services.settings_repo.get(OLLAMA_SERVICE_SETTINGS_KEY) if raw is None: return False return raw.strip().lower() in {"1", "true", "yes", "on"} @@ -289,7 +287,7 @@ async def put_ollama_service( ) server.services.settings_repo.set( - _SETTINGS_KEY_OLLAMA_SERVICE, + OLLAMA_SERVICE_SETTINGS_KEY, "true" if body.enabled else "false", ) if body.enabled: diff --git a/src/octop/i18n/domains/stream.py b/src/octop/i18n/domains/stream.py index 05d4bb6c..4d627087 100644 --- a/src/octop/i18n/domains/stream.py +++ b/src/octop/i18n/domains/stream.py @@ -16,11 +16,13 @@ TIMEOUT_NETWORK = f"{_PREFIX}stream_errors.timeout_network" PROVIDER_UNAVAILABLE = f"{_PREFIX}stream_errors.provider_unavailable" MODEL_CALL_FAILED = f"{_PREFIX}stream_errors.model_call_failed" +LOCAL_RUNTIME = f"{_PREFIX}stream_errors.local_runtime" __all__ = [ "AUTH", "CONTEXT_LENGTH", "INSUFFICIENT_BALANCE", + "LOCAL_RUNTIME", "MODEL_CALL_FAILED", "PROVIDER_UNAVAILABLE", "RATE_LIMIT", @@ -118,6 +120,16 @@ def classify_stream_error_message(message: str) -> str | None: ): return RECURSION_LIMIT + if ("11434" in lower or "ollama" in lower) and ( + "connect" in lower + or "refused" in lower + or "unreachable" in lower + or "not running" in lower + or "connection" in lower + or "errno 111" in lower + ): + return LOCAL_RUNTIME + if ( "internalservererror" in compact or "bad gateway" in lower diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index e1cadd18..c6493991 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -596,7 +596,8 @@ "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", "provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.", - "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models." + "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.", + "local_runtime": "The local Ollama runtime is not reachable. Start Ollama from Settings → Models, then retry. If it is already running, confirm it is listening on port 11434." }, "attachment": { "header": "[Attachment] {filename}", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index a8c5d1e7..28d9d639 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -596,7 +596,8 @@ "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", "provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。", - "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。" + "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。", + "local_runtime": "本机 Ollama 未就绪或无法连接。请到「设置 → 模型」启动 Ollama 后再试。若已启动,请确认它在 11434 端口监听。" }, "attachment": { "header": "[附件] {filename}", diff --git a/src/octop/infra/agents/providers/local_register.py b/src/octop/infra/agents/providers/local_register.py index 6cb6c9c3..034057ba 100644 --- a/src/octop/infra/agents/providers/local_register.py +++ b/src/octop/infra/agents/providers/local_register.py @@ -8,7 +8,12 @@ from pathlib import Path from typing import Any -from octop.infra.agents.providers.model_flags import is_ollama_local_provider +from octop.infra.agents.providers.model_flags import ( + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_PROVIDER_DISPLAY_NAME, + OLLAMA_SERVICE_SETTINGS_KEY, + is_ollama_local_provider, +) from octop.infra.utils.ollama_manager import OllamaModelManager, create_from_weight logger = logging.getLogger(__name__) @@ -16,7 +21,6 @@ _SETTINGS_KEY = "local_registered_weights" # Keep Ollama tags (``llama3.2:1b``) while sanitizing Windows paths / file stems. _NAME_SAFE = re.compile(r"[^a-z0-9._:-]+") -_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1" _WEIGHT_SOURCES = frozenset({"gguf", "ggml"}) _REGISTER_SOURCES = frozenset({*_WEIGHT_SOURCES, "ollama"}) @@ -70,7 +74,16 @@ def _find_ollama_row(provider_repo: Any) -> Any | None: return None -def _upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str: +def ensure_ollama_service_flag(settings_repo: Any) -> None: + """Mark the Ollama runtime as enabled so chat / Models cards stay in sync.""" + setter = getattr(settings_repo, "set", None) + if not callable(setter): + return + setter(OLLAMA_SERVICE_SETTINGS_KEY, "true") + + +def upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str: + """Add *model_id* to the local Ollama provider and make it chat-usable.""" row = _find_ollama_row(provider_repo) model = { "id": model_id, @@ -81,19 +94,19 @@ def _upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str if row is None: try: provider_repo.create( - name="Ollama (Local)", - kind="ollama", - base_url=_OLLAMA_BASE_URL, + name=OLLAMA_PROVIDER_DISPLAY_NAME, + kind="openai", + base_url=OLLAMA_DEFAULT_BASE_URL, api_key="ollama", models_json=json.dumps([model]), ) - return "Ollama (Local)" + return OLLAMA_PROVIDER_DISPLAY_NAME except Exception as exc: existing = getattr(provider_repo, "get_by_name", None) - row = existing("Ollama (Local)") if callable(existing) else None + row = existing(OLLAMA_PROVIDER_DISPLAY_NAME) if callable(existing) else None if row is None: raise OSError(f"Could not save the Ollama provider: {exc}") from exc - models = row.get_models() + models = [item for item in row.get_models() if isinstance(item, dict)] existing = next((item for item in models if str(item.get("id") or "") == model_id), None) if existing is None: models.append(model) @@ -104,7 +117,9 @@ def _upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str row.id, models_json=json.dumps(models), enabled=True, - base_url=row.base_url or _OLLAMA_BASE_URL, + kind=getattr(row, "kind", None) or "openai", + api_key=getattr(row, "api_key", None) or "ollama", + base_url=getattr(row, "base_url", None) or OLLAMA_DEFAULT_BASE_URL, ) return str(row.name) @@ -188,7 +203,7 @@ def register_local_weight( except Exception as exc: logger.warning("Ollama list after register failed for %s: %s", model_id, exc) try: - provider_name = _upsert_ollama_model(provider_repo, model_id, model_id) + provider_name = upsert_ollama_model(provider_repo, model_id, model_id) except Exception as exc: return _fail(f"Could not save the model provider: {exc}") entry = { @@ -204,6 +219,12 @@ def register_local_weight( remember_weight(settings_repo, entry) except Exception as exc: return _fail(f"Could not remember the registered model: {exc}") + try: + ensure_ollama_service_flag(settings_repo) + except Exception as exc: + logger.warning( + "Registered %s but could not enable the Ollama service flag: %s", model_id, exc + ) return { "ok": True, "action": "registered", diff --git a/src/octop/infra/agents/providers/model_flags.py b/src/octop/infra/agents/providers/model_flags.py index e654abab..3718b490 100644 --- a/src/octop/infra/agents/providers/model_flags.py +++ b/src/octop/infra/agents/providers/model_flags.py @@ -11,6 +11,9 @@ # Same pattern for Ollama (placeholder api_key + exact preset names). _OLLAMA_PRESET_API_KEY = "ollama" _OLLAMA_PRESET_NAMES = frozenset({"ollama", "ollama (local)"}) +OLLAMA_PROVIDER_DISPLAY_NAME = "Ollama (Local)" +OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1" +OLLAMA_SERVICE_SETTINGS_KEY = "ollama_service_enabled" def is_onnx_local_provider( @@ -52,6 +55,24 @@ def is_ollama_local_provider( return "11434" in url or "ollama" in url +def local_ollama_endpoint( + provider_name: str | None = None, + *, + provider_api_key: str | None = None, + provider_base_url: str | None = None, +) -> tuple[str | None, str | None]: + """Return ``(api_key, base_url)``, filling Ollama defaults for local rows.""" + api_key = (provider_api_key or "").strip() or None + base_url = (provider_base_url or "").strip() or None + if is_ollama_local_provider( + provider_name, + provider_api_key=api_key, + provider_base_url=base_url, + ): + return api_key or _OLLAMA_PRESET_API_KEY, base_url or OLLAMA_DEFAULT_BASE_URL + return api_key, base_url + + def is_local_runtime_provider( provider_name: str | None = None, *, diff --git a/src/octop/infra/agents/providers/resolved.py b/src/octop/infra/agents/providers/resolved.py index 3f09a535..8389db80 100644 --- a/src/octop/infra/agents/providers/resolved.py +++ b/src/octop/infra/agents/providers/resolved.py @@ -4,7 +4,10 @@ from typing import Any -from octop.infra.agents.providers.model_flags import is_chat_eligible_model +from octop.infra.agents.providers.model_flags import ( + is_chat_eligible_model, + local_ollama_endpoint, +) from octop.infra.agents.providers.reasoning import reasoning_capability @@ -16,14 +19,20 @@ def list_resolved_models(providers: list[Any]) -> list[dict[str, Any]]: """ resolved: list[dict[str, Any]] = [] for provider in providers: - if not provider.enabled or not provider.api_key: + if not provider.enabled: continue provider_name = str(getattr(provider, "name", "") or "") provider_api_key = str(getattr(provider, "api_key", "") or "") or None + provider_base_url = str(getattr(provider, "base_url", "") or "") or None + api_key, _base_url = local_ollama_endpoint( + provider_name, + provider_api_key=provider_api_key, + provider_base_url=provider_base_url, + ) + if not api_key: + continue for m in provider.get_models(): - if not is_chat_eligible_model( - m, provider_name=provider_name, provider_api_key=provider_api_key - ): + if not is_chat_eligible_model(m, provider_name=provider_name, provider_api_key=api_key): continue # Total context drives the UI ring; prompt and output caps remain # separately available for runtime budgeting and model settings. diff --git a/src/octop/infra/agents/providers/store.py b/src/octop/infra/agents/providers/store.py index e419b241..611985e7 100644 --- a/src/octop/infra/agents/providers/store.py +++ b/src/octop/infra/agents/providers/store.py @@ -8,7 +8,12 @@ from harness_agent.config import ModelConfig, ProviderConfig -from octop.infra.agents.providers.model_flags import is_chat_eligible_model, is_vision_model +from octop.infra.agents.providers.model_flags import ( + is_chat_eligible_model, + is_ollama_local_provider, + is_vision_model, + local_ollama_endpoint, +) from octop.infra.agents.providers.opencode_session import ( OPENCODE_SESSION_HEADER, is_opencode_go_base_url, @@ -111,18 +116,44 @@ def __init__( ) -> None: self._provider_repo = provider_repo + def _row_credentials(self, row: Any) -> tuple[str | None, str | None]: + return local_ollama_endpoint( + getattr(row, "name", None), + provider_api_key=getattr(row, "api_key", None), + provider_base_url=getattr(row, "base_url", None), + ) + + def _row_for_model_ref(self, ref: str) -> tuple[Any | None, str]: + """Resolve ``provider/model``; local aliases like ``ollama/`` map to the Ollama row.""" + provider_name, _, model_id = ref.partition("/") + if not provider_name or not model_id: + return None, model_id + row = self._provider_repo.get_by_name(provider_name) + if row is not None: + return row, model_id + if is_ollama_local_provider(provider_name): + for candidate in self._provider_repo.list_all(): + if is_ollama_local_provider( + candidate.name, + provider_api_key=candidate.api_key, + provider_base_url=candidate.base_url, + ): + return candidate, model_id + return None, model_id + def iter_usable_rows(self) -> Iterator[Any]: """Yield enabled DB providers that have credentials and at least one enabled model.""" for row in self._provider_repo.list_all(): if not row.enabled: continue - if not row.base_url or not row.api_key: + api_key, base_url = self._row_credentials(row) + if not base_url or not api_key: continue models = row.get_models() if not models: continue if any( - is_chat_eligible_model(m, provider_name=row.name, provider_api_key=row.api_key) + is_chat_eligible_model(m, provider_name=row.name, provider_api_key=api_key) for m in models ): yield row @@ -135,11 +166,14 @@ def build_harness_configs(self) -> list[ProviderConfig]: out: list[ProviderConfig] = [] for row in self.iter_usable_rows(): protocol = KIND_TO_PROTOCOL.get(row.kind, "openai") + api_key, base_url = self._row_credentials(row) + if not api_key or not base_url: + continue raw_models = json.loads(row.models_json) if getattr(row, "models_json", None) else [] models = [ self._model_config_from_row(m) for m in raw_models - if is_chat_eligible_model(m, provider_name=row.name, provider_api_key=row.api_key) + if is_chat_eligible_model(m, provider_name=row.name, provider_api_key=api_key) ] if not models: continue @@ -154,14 +188,14 @@ def build_harness_configs(self) -> list[ProviderConfig]: out.append( ProviderConfig( id=row.name, - base_url=row.base_url, - api_key=row.api_key, + base_url=base_url, + api_key=api_key, protocol=protocol, # type: ignore[arg-type] name=row.name, models=models, headers=headers, session_header=( - OPENCODE_SESSION_HEADER if is_opencode_go_base_url(row.base_url) else None + OPENCODE_SESSION_HEADER if is_opencode_go_base_url(base_url) else None ), ) ) @@ -183,13 +217,13 @@ def is_model_ref_multimodal(self, ref: str) -> bool: ref = ref.strip() if not ref or "/" not in ref: return False - provider_name, _, model_id = ref.partition("/") - row = self._provider_repo.get_by_name(provider_name) - if row is None: + row, model_id = self._row_for_model_ref(ref) + if row is None or not model_id: return False + api_key, _base_url = self._row_credentials(row) for model in row.get_models(): if model.get("id") == model_id and is_chat_eligible_model( - model, provider_name=provider_name, provider_api_key=row.api_key + model, provider_name=row.name, provider_api_key=api_key ): return _model_dict_supports_image(model) return False @@ -239,27 +273,24 @@ def is_model_ref_usable(self, ref: str) -> bool: ref = ref.strip() if not ref or ref.lower() == "auto" or "/" not in ref: return False - provider_name, _, model_id = ref.partition("/") - if not provider_name or not model_id: + row, model_id = self._row_for_model_ref(ref) + if row is None or not row.enabled or not model_id: return False - row = self._provider_repo.get_by_name(provider_name) - if row is None or not row.enabled or not row.api_key or not row.base_url: + api_key, base_url = self._row_credentials(row) + if not api_key or not base_url: return False for model in row.get_models(): if model.get("id") != model_id: continue - return is_chat_eligible_model( - model, provider_name=provider_name, provider_api_key=row.api_key - ) + return is_chat_eligible_model(model, provider_name=row.name, provider_api_key=api_key) return False def get_model_reasoning_capability(self, ref: str) -> dict[str, Any] | None: """Return normalized reasoning metadata for a usable model ref.""" if not self.is_model_ref_usable(ref): return None - provider_name, _, model_id = ref.partition("/") - row = self._provider_repo.get_by_name(provider_name) - if row is None: + row, model_id = self._row_for_model_ref(ref) + if row is None or not model_id: return None for model in row.get_models(): if model.get("id") == model_id and model.get("enabled", True): diff --git a/tests/unit/agents/test_model_flags.py b/tests/unit/agents/test_model_flags.py index fa497566..903d9f69 100644 --- a/tests/unit/agents/test_model_flags.py +++ b/tests/unit/agents/test_model_flags.py @@ -127,3 +127,19 @@ def test_list_resolved_models_skips_embedding() -> None: assert resolved[0]["max_input_tokens"] == 120_000 assert resolved[0]["max_output_tokens"] == 16_384 assert resolved[0]["max_tokens"] == 16_384 + + +def test_list_resolved_models_includes_ollama_without_api_key() -> None: + providers = [ + SimpleNamespace( + id=1, + name="Ollama (Local)", + kind="openai", + enabled=True, + api_key="", + base_url="", + get_models=lambda: [{"id": "tiny", "name": "tiny", "enabled": True}], + ) + ] + resolved = list_resolved_models(providers) + assert [(r["provider_name"], r["model"]) for r in resolved] == [("Ollama (Local)", "tiny")] diff --git a/tests/unit/agents/test_provider_store.py b/tests/unit/agents/test_provider_store.py index df4b3183..b14af7c6 100644 --- a/tests/unit/agents/test_provider_store.py +++ b/tests/unit/agents/test_provider_store.py @@ -322,6 +322,23 @@ def test_resolve_first_model_ref_returns_first_enabled_model(store: ProviderStor assert store.resolve_first_model_ref() == "p1/ready" +def test_ollama_defaults_make_local_ref_usable(store: ProviderStore) -> None: + store._provider_repo.create( + name="Ollama (Local)", + kind="openai", + base_url="http://127.0.0.1:11434/v1", + api_key="", + models_json=json.dumps([{"id": "tiny", "name": "tiny", "enabled": True}]), + ) + assert store.is_model_ref_usable("Ollama (Local)/tiny") is True + assert store.is_model_ref_usable("ollama/tiny") is True + configs = store.build_harness_configs() + assert configs[0].id == "Ollama (Local)" + assert configs[0].base_url == "http://127.0.0.1:11434/v1" + assert configs[0].api_key == "ollama" + assert [model.id for model in configs[0].models] == ["tiny"] + + def test_resolve_multimodal_model_ref_prefers_inferred_vision_model(store: ProviderStore) -> None: store._provider_repo.create( name="p", diff --git a/tests/unit/i18n/test_stream.py b/tests/unit/i18n/test_stream.py index 9e756757..53af13ad 100644 --- a/tests/unit/i18n/test_stream.py +++ b/tests/unit/i18n/test_stream.py @@ -3,6 +3,7 @@ from __future__ import annotations from octop.i18n.domains.stream import ( + LOCAL_RUNTIME, MODEL_CALL_FAILED, RECURSION_LIMIT, STREAM_STALL, @@ -90,6 +91,19 @@ def test_classify_recursion_limit() -> None: ) +def test_classify_local_runtime() -> None: + assert ( + classify_stream_error_message("Failed to connect to ollama at http://127.0.0.1:11434") + == LOCAL_RUNTIME + ) + assert classify_stream_error_message("Connection refused [Errno 111] 127.0.0.1:11434") == ( + LOCAL_RUNTIME + ) + text = format_stream_error("Failed to connect to ollama", "zh") + assert "Ollama" in text + assert "11434" in text + + def test_classify_model_call_failed_fallback() -> None: assert ( classify_stream_error_message("Model call failed after 3 attempts with RuntimeError: boom") diff --git a/tests/unit/test_local_register.py b/tests/unit/test_local_register.py index 505efdb6..35ca7386 100644 --- a/tests/unit/test_local_register.py +++ b/tests/unit/test_local_register.py @@ -44,19 +44,34 @@ def _models() -> list[dict[str, object]]: return data if isinstance(data, list) else [] row = SimpleNamespace( - id=1, + id=len(self.rows) + 1, name=kwargs["name"], kind=kwargs["kind"], base_url=kwargs.get("base_url"), api_key=kwargs.get("api_key"), + enabled=kwargs.get("enabled", True), models_json=models_json, get_models=_models, ) self.rows.append(row) - return 1 + return row.id def update(self, provider_id: int, **kwargs: object) -> None: - del provider_id, kwargs + row = next((item for item in self.rows if item.id == provider_id), None) + if row is None: + return + for key, value in kwargs.items(): + setattr(row, key, value) + models_json = kwargs.get("models_json") + if isinstance(models_json, str): + + def _models() -> list[dict[str, object]]: + import json + + data = json.loads(models_json) + return data if isinstance(data, list) else [] + + row.get_models = _models def test_sanitize_model_name() -> None: @@ -111,6 +126,7 @@ def _list() -> list[SimpleNamespace]: assert providers.rows[0].name == "Ollama (Local)" assert providers.rows[0].base_url == "http://127.0.0.1:11434/v1" assert "tiny.gguf" in settings.data.get("local_registered_weights", "") + assert settings.data.get("ollama_service_enabled") == "true" def test_register_ollama_source_skips_create(monkeypatch: pytest.MonkeyPatch) -> None: @@ -223,6 +239,38 @@ def _boom() -> list[object]: assert created == [("tiny", "/tmp/tiny.gguf")] +def test_register_enables_existing_local_service_row(monkeypatch: pytest.MonkeyPatch) -> None: + import json + + monkeypatch.setattr( + "octop.infra.agents.providers.local_register.OllamaModelManager.list_models", + lambda: [SimpleNamespace(name="llama3.2:1b", size=99)], + ) + providers = _Providers() + providers.create( + name="Ollama (Local)", + kind="openai", + base_url="http://127.0.0.1:11434/v1", + api_key="ollama", + models_json=json.dumps([{"id": "preset", "name": "preset", "enabled": False}]), + enabled=False, + ) + settings = _Settings() + result = register_local_weight( + path="", + name="llama3.2:1b", + source="ollama", + size=99, + provider_repo=providers, + settings_repo=settings, + ) + assert result["ok"] is True + assert providers.rows[0].enabled is True + models = providers.rows[0].get_models() + assert any(item.get("id") == "llama3.2:1b" and item.get("enabled") for item in models) + assert settings.data.get("ollama_service_enabled") == "true" + + def test_remember_weight_keys_ollama_rows_by_name() -> None: settings = _Settings() remember_weight(settings, {"name": "a", "path": "", "source": "ollama"})