From 2f141c2fad528880be63046ad99389e986318945 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 10:56:16 +0000 Subject: [PATCH 1/2] feat: local models and knowledge bases as first-class (P1.4) Prefer Ollama and local OpenAI-compatible endpoints in the setup wizard and Models page; keep cloud providers optional. Knowledge empty state leads with a local folder mount and ONNX embeddings. Co-authored-by: XYAI Labs --- CHANGELOG.md | 4 + README.md | 12 +- README.zh-CN.md | 2 + README_CN.md | 4 +- dashboard/src/locales/en.json | 32 +++-- dashboard/src/locales/zh.json | 32 +++-- .../KnowledgeMountHome.test.tsx | 6 + .../KnowledgeBases/KnowledgeMountHome.tsx | 8 +- .../pages/KnowledgeBases/index.module.less | 15 +++ .../components/modals/CustomProviderModal.tsx | 41 ++++-- dashboard/src/pages/Settings/Models/index.tsx | 32 +++-- .../pages/Settings/Models/presetUtils.test.ts | 64 +++++++++- .../src/pages/Settings/Models/presetUtils.ts | 86 ++++++++++++- dashboard/src/pages/Setup/steps/ModelStep.tsx | 117 ++++++++++-------- desktop/README.md | 4 + docs/configuration.md | 16 ++- docs/org-capability-migration-map.json | 30 ++++- docs/org-capability-migration-map.md | 9 +- docs/org-capability-migration-map.zh-CN.md | 9 +- docs/user-guide.md | 95 ++++++++------ src/octop/api/routers/providers.py | 21 +++- src/octop/api/routers/setup.py | 25 ++-- .../infra/agents/providers/model_flags.py | 23 ++++ src/octop/infra/agents/providers/presets.py | 12 ++ src/octop/infra/utils/local_endpoint.py | 39 ++++++ tests/unit/knowledge/test_gate.py | 1 + tests/unit/test_provider_preset_expansion.py | 2 + tests/unit/utils/test_local_endpoint.py | 40 ++++++ 28 files changed, 609 insertions(+), 172 deletions(-) create mode 100644 src/octop/infra/utils/local_endpoint.py create mode 100644 tests/unit/utils/test_local_endpoint.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d0cab7..4d5adb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 变更 + +- P1.4:本机模型与本机知识库作为默认路径。设置向导默认先选 Ollama(本机 OpenAI 兼容地址不必填云密钥);模型页默认打开「本地」标签;知识库空状态先引导本机文件夹,向量默认本机 ONNX。云厂商与 ima 仍是可选项。说明见 `docs/user-guide.md`、`docs/configuration.md`。 + ### 文档 - 冻结 FreeOS 产品契约(P0.1):[docs/product-contract.md](docs/product-contract.md) / [docs/product-contract.zh-CN.md](docs/product-contract.zh-CN.md)。核心愿景采用「创立初心」定稿(中文原文;英文 README 为忠实对应;使用方式段为工作室比喻:本机安顿起步,组织像另一间可独立布置的房间)。产品口号:**FreeOS:自由的 AI 工作室,想象空间由你来打开** / *Your FreeOS, free for you.* 托管 Node / 内嵌组织页仅为过渡桥,终态是把 openXYOS 迁入宿主并导出可商业化源码。取代先前「组织身份为唯一权威」与「永久 / 默认捆绑 Node 运行时」表述。冲突文档改为 Historical vs Current,不删工程历史。 diff --git a/README.md b/README.md index 8d6a72cc..f64e37f1 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,17 @@ uv run freeos run `freeos` is the product CLI. `octop` remains a package-compatible alias. -Open the dashboard (default listen port is printed by `run`, commonly `http://127.0.0.1:18900`). Complete the first-run wizard. +Open the dashboard (default listen port is printed by `run`, commonly `http://127.0.0.1:18900`). Complete the first-run wizard — **Ollama / a local OpenAI-compatible URL is the first choice**; cloud keys stay optional. + +### Local models and knowledge bases + +FreeOS prefers runtimes and files on this computer. Cloud vendors are optional. + +| What | Where | +|---|---| +| Chat models | First-run wizard (Ollama selected first) or **Models → Local**: start Ollama, register GGUF, or add a local OpenAI-compatible URL (LM Studio, llama.cpp, vLLM). | +| Knowledge | Sidebar **Knowledge Bases**: mount a local folder first. Local ONNX embeddings are the default vector path. ima / WeKnora remain optional connectors. | +| Detail | [User guide](docs/user-guide.md#四配置模型llm-供应商) · [Configuration](docs/configuration.md#local-models-and-knowledge-bases) · [Product contract](docs/product-contract.md#local-models-and-knowledge-bases) | Data directory precedence: diff --git a/README.zh-CN.md b/README.zh-CN.md index 6cf0efe8..bdde5f63 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,6 +43,8 @@ uv sync uv run freeos run ``` +第一次打开控制台时,向导默认先选 **Ollama / 本机 OpenAI 兼容地址**,不必先填云厂商密钥。侧栏 **模型 → 本地** 可启动 Ollama、注册 GGUF;**知识库** 先挂接本机文件夹,向量默认本机 ONNX。说明见 [用户指南](docs/user-guide.md)、[配置](docs/configuration.md#local-models-and-knowledge-bases)。 + 不要将 API Key、令牌或真实客户资料提交到公开仓库。 完整功能、架构和发布说明请见 [English README](README.md)、[中文宿主说明](README_CN.md) 与 [Wiki](https://github.com/XYAIStudio/FreeOS/wiki)。openXYOS 能力哪些已在宿主原生、哪些仍在托管 Node / iframe,见 [能力迁移图(P0.2)](docs/org-capability-migration-map.zh-CN.md)。 diff --git a/README_CN.md b/README_CN.md index 7da53cbb..92cd08a3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -276,7 +276,7 @@ octop run --host 0.0.0.0 --port 8088 octop service start ``` -打开 **http://127.0.0.1:8088**。Docker 首次初始化会自动生成随机管理员密码(写入 `/data/.octop/credential.txt`),除非设置了 `OCTOP_DEFAULT_PASSWORD`。交互式 `octop init` / 设置向导会让你自行设置密码(至少 8 位,且同时包含字母和数字)。 +打开 **http://127.0.0.1:8088**。Docker 首次初始化会自动生成随机管理员密码(写入 `/data/.octop/credential.txt`),除非设置了 `OCTOP_DEFAULT_PASSWORD`。交互式 `octop init` / 设置向导会让你自行设置密码(至少 8 位,且同时包含字母和数字)。向导模型步默认先选本机 Ollama;知识库默认走本机文件夹 + ONNX 向量,云厂商与 ima 是可选项。详见 [docs/user-guide.md](docs/user-guide.md)。 ### Docker(推荐用于生产部署) @@ -384,7 +384,7 @@ octop user list ### 支持的 LLM 供应商 -OpenAI 兼容 API、DashScope(千问)、Ollama 等预设 — 在控制台或通过 `octop provider` 按 Agent 配置。 +**本机优先:** Ollama、本机 OpenAI 兼容端点(LM Studio / llama.cpp / vLLM)、本机 ONNX Embedding。云厂商(OpenAI 兼容 API、DashScope 等)是可选项 — 在向导或控制台「模型 → 本地」先配本机,再按需加云。 ### 支持的通道 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 70a41b82..ebd494b4 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -557,19 +557,21 @@ }, "knowledgeBases": { "title": "Knowledge Bases", - "subtitle": "Mount a local folder or cloud knowledge base first. Cleaning, labeling, and distillation come later.", + "subtitle": "Start with a folder on this computer. Cloud mounts stay optional. Cleaning, labeling, and distillation come later.", "featureTitle": "Enable Knowledge Bases", "settingsTitle": "Knowledge base settings", "settingsFoundation": "Foundation settings", - "settingsFoundationLead": "These settings are for later cleaning, labeling, and distillation. You can mount sources first without changing them.", + "settingsFoundationLead": "Vector models default to local ONNX. These settings are for later cleaning, labeling, and distillation. You can mount a local folder first without changing them.", "settingsTabVector": "Vector model", "settingsTabImage": "Images", "settingsTabOcr": "Scanned-document OCR", "settingsLead": "Advanced switch. It is on by default on the desktop. Turning it off hides knowledge bases from colleagues.", "settingsOpen": "Knowledge base feature", - "homeTitle": "Mount your sources first", - "homeDesc": "Pick a folder on this computer, or connect ima with the official Agent Interface and choose which knowledge bases and documents to attach. The original folder stays read-only. Parse results go to a new folder you choose. Colleagues can look them up in chat.", - "homeAdvancedHint": "Vector, image, and scanned-document OCR settings are for later cleaning, labeling, and distillation.", + "homeTitle": "Keep knowledge on this computer", + "homeDesc": "Mount a folder on this computer first so chat and colleagues retrieve local files. Connecting ima is optional. The original folder stays read-only; parse results go to a new folder you choose.", + "homeRecommended": "Start here · local folder", + "homeOptionalCloud": "Optional cloud", + "homeAdvancedHint": "Local ONNX embeddings, images, and scanned-document OCR are for later cleaning, labeling, and distillation.", "defaultLocalName": "Local files", "defaultCloudName": "Cloud knowledge", "pickFolder": "Choose folder", @@ -578,7 +580,7 @@ "imageSettingsDesc": "Turn this on to recognize text in pictures, so you can clean and label them later.", "ocrFoundationHint": "Turn this on so scanned PDFs can be read as text for later distillation.", "disabledTitle": "Knowledge bases are turned off", - "disabledDesc": "You can turn the feature back on in foundation settings. After that, mount a local folder or a cloud knowledge base to start.", + "disabledDesc": "You can turn the feature back on in foundation settings. After that, mount a local folder first; cloud knowledge bases stay optional.", "manageModels": "Manage models", "unavailableTitle": "Knowledge bases are not ready", "unavailableDescription": "Open Foundation settings in the top-right to turn knowledge bases on.", @@ -591,7 +593,7 @@ "description": "Description", "empty": "No knowledge bases yet", "emptyGuideTitle": "No knowledge bases yet", - "emptyGuideDesc": "Turn documents into a searchable library for chat citations, and optionally share them with users on this instance.", + "emptyGuideDesc": "Turn local documents into a searchable library for chat citations. Sharing with users on this instance is optional.", "emptyGuideStepWhat": "What is a knowledge base?", "emptyGuideStepWhatDetail": "A knowledge base splits and embeds uploaded documents so experts can retrieve relevant passages in chat, instead of stuffing the full text into context.", "emptyGuideStepHow": "How to use it", @@ -605,7 +607,7 @@ "enableGuideStepToggle": "Enable knowledge bases", "enableGuideStepToggleDetail": "Turn on Enable knowledge bases. When off, knowledge bases are unavailable for everyone.", "enableGuideStepModel": "Configure an embedding model", - "enableGuideStepModelDetail": "Choose local ONNX (download the model first) or an online embedding provider, then save.", + "enableGuideStepModelDetail": "Choose local ONNX (download the model first). An online embedding provider is optional, then save.", "noDescription": "No description", "selectBase": "Select a knowledge base, or mount a source first", "mountTitle": "Local folder mount", @@ -3141,7 +3143,7 @@ "presetLocal": "Local", "noPresetProvidersInTab": "No providers in this tab", "customProviders": "Custom Providers", - "noCustomProvidersHint": "No custom providers yet. Click \"Add provider\" above to create one.", + "noCustomProvidersHint": "No custom providers yet. Add a local OpenAI-compatible endpoint (Ollama, LM Studio, llama.cpp) or a cloud gateway.", "builtinProviders": "Built-in Providers", "addProvider": "Add Provider", "addProviderTitle": "Add Custom Provider", @@ -3196,7 +3198,7 @@ "localDownloadPending": "Preparing to download...", "localDownloading": "Downloading {{repo}}... This may take a few minutes.", "localHardwareTitle": "This PC", - "localHardwareHint": "Detected RAM, CPU, optional NVIDIA GPU, Ollama, and already-downloaded GGUF / Ollama models. Chat can switch to a registered local model to reduce cloud use.", + "localHardwareHint": "Start here: detected RAM, CPU, optional GPU, Ollama, and already-downloaded GGUF / Ollama models. Register a local model for chat. Cloud providers stay optional.", "localCpu": "CPU cores", "localRam": "RAM", "localGpu": "GPU", @@ -3365,6 +3367,7 @@ "nameLabel": "Name", "namePlaceholder": "e.g., my-openai", "baseUrlExtra": "OpenAI-compatible endpoint base URL. Leave blank to use the official default.", + "baseUrlExtraLocal": "Prefer a local OpenAI-compatible URL, such as Ollama (http://127.0.0.1:11434/v1), LM Studio (:1234/v1), or llama.cpp (:8080/v1). Cloud URLs stay optional.", "defaultModelLabel": "Default Model", "defaultModelPlaceholder": "e.g., gpt-4o-mini", "noteLabel": "Note (optional)", @@ -4720,7 +4723,7 @@ "intro": "Create the first administrator account." }, "model": { - "intro": "Configure one model provider so the assistant can reply. You can skip this step and configure it later.", + "intro": "Start with a local runtime such as Ollama, or a local OpenAI-compatible URL. Cloud providers are optional. You can skip and finish later on Models.", "skip": "Skip", "skipToWorkspace": "Skip and enter workspace", "presetTab": "Preset provider", @@ -4728,7 +4731,10 @@ "showMorePresets": "Show more providers ({{count}})", "hideMorePresets": "Show less", "choosePreset": "Choose a preset provider", - "choosePresetHint": "Click a card to pre-fill settings — you only need to enter your API key. Multi-site brands open a site picker first.", + "choosePresetHint": "Ollama is selected first so chat can stay on this computer. Cloud cards still work if you need them — you only enter an API key for those.", + "localRecommended": "This computer", + "localOptionalTest": "If Ollama or another local runtime is already up, test the connection. You can also continue and pull models later on Models → Local.", + "continueLocal": "Continue with local setup", "siteCount": "{{count}} sites", "customSetup": "Custom setup", "testButton": "Test connection", @@ -5668,7 +5674,7 @@ }, "models": { "title": "Model Management", - "subtitle": "Configure conversation, generation, voice, and search engines" + "subtitle": "Prefer models on this computer. Cloud, generation, voice, and search stay optional." }, "voice": { "title": "Voice Services", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 45758399..43f8da3a 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -557,19 +557,21 @@ }, "knowledgeBases": { "title": "知识库", - "subtitle": "先挂接本地文件夹或云知识库,再按需做清洗、标注和蒸馏。", + "subtitle": "先挂接这台电脑上的文件夹。云知识库是可选项。清洗、标注和蒸馏可以稍后做。", "featureTitle": "启用知识库", "settingsTitle": "知识库设置", "settingsFoundation": "基础设置", - "settingsFoundationLead": "这些是后续清洗、标注、蒸馏用的基础设置。先挂接资料即可开始,不必先改这里。", + "settingsFoundationLead": "向量模型默认用本机 ONNX。这些是后续清洗、标注、蒸馏用的基础设置。先挂接本机文件夹即可开始,不必先改这里。", "settingsTabVector": "向量模型", "settingsTabImage": "图片", "settingsTabOcr": "扫描件 OCR", "settingsLead": "高级开关。桌面默认已打开。关掉后,同事都无法再使用知识库。", "settingsOpen": "知识库功能", - "homeTitle": "先把资料挂进来", - "homeDesc": "选电脑上的文件夹,或通过官方 IMA Agent Interface 连接 ima,再自选要接入的知识库和文档。原来的文件夹只读,不会被改动;解析结果会存到你另选的新文件夹。同事可以在对话里查阅。", - "homeAdvancedHint": "向量模型、图片和扫描件 OCR 留给后续清洗、标注、蒸馏。", + "homeTitle": "知识先留在这台电脑上", + "homeDesc": "先挂接本机文件夹,对话和同事检索的就是本地资料。连接 ima 是可选项。原来的文件夹只读,不会被改动;解析结果会存到你另选的新文件夹。", + "homeRecommended": "从这里开始 · 本机文件夹", + "homeOptionalCloud": "可选云端", + "homeAdvancedHint": "本机 ONNX 向量、图片和扫描件 OCR 留给后续清洗、标注、蒸馏。", "defaultLocalName": "本地资料", "defaultCloudName": "云知识库", "pickFolder": "选择文件夹", @@ -578,7 +580,7 @@ "imageSettingsDesc": "打开后,可以把图片里的字认出来,方便以后清洗和标注。", "ocrFoundationHint": "打开后,扫描件 PDF 也能认成文字,方便以后蒸馏。", "disabledTitle": "知识库功能已关闭", - "disabledDesc": "可以在基础设置里重新打开。打开后,先挂接本地文件夹或云知识库即可使用。", + "disabledDesc": "可以在基础设置里重新打开。打开后,先挂接本机文件夹即可使用;云知识库仍是可选项。", "manageModels": "管理模型", "unavailableTitle": "知识库尚未就绪", "unavailableDescription": "请先在右上角基础设置中打开知识库。", @@ -591,7 +593,7 @@ "description": "描述", "empty": "暂无知识库", "emptyGuideTitle": "还没有知识库", - "emptyGuideDesc": "把文档做成可检索的资料库,对话时引用,也可按需共享给同实例用户。", + "emptyGuideDesc": "把本机文档做成可检索的资料库,对话时引用。共享给同实例用户是可选项。", "emptyGuideStepWhat": "什么是知识库?", "emptyGuideStepWhatDetail": "知识库会把上传的文档切分并向量化。对话时专家可以检索引用相关段落,而不必把全文塞进上下文。", "emptyGuideStepHow": "知识库怎么用", @@ -605,7 +607,7 @@ "enableGuideStepToggle": "启用知识库", "enableGuideStepToggleDetail": "打开「启用知识库」开关。关闭后全实例都无法使用知识库。", "enableGuideStepModel": "配置向量模型", - "enableGuideStepModelDetail": "选择本地 ONNX(需先下载模型)或在线 Embedding 提供商,保存后即可使用。", + "enableGuideStepModelDetail": "优先选择本机 ONNX(需先下载模型)。在线 Embedding 提供商是可选项,保存后即可使用。", "noDescription": "暂无描述", "selectBase": "选一个知识库查看文档,或先挂接资料", "mountTitle": "本地文件夹挂接", @@ -3139,7 +3141,7 @@ "presetLocal": "本地", "noPresetProvidersInTab": "暂无提供商", "customProviders": "自定义提供商", - "noCustomProvidersHint": "暂无自定义提供商,点击右上角「自定义供应商」添加", + "noCustomProvidersHint": "还没有自定义提供商。可添加本机 OpenAI 兼容端点(Ollama、LM Studio、llama.cpp),或云网关。", "builtinProviders": "内置提供商", "addProvider": "添加提供商", "addProviderTitle": "添加自定义提供商", @@ -3194,7 +3196,7 @@ "localDownloadPending": "准备下载...", "localDownloading": "正在下载 {{repo}}... 可能需要几分钟。", "localHardwareTitle": "本机", - "localHardwareHint": "已检测内存、CPU、NVIDIA 显卡(如有)、Ollama 以及本机已有的 GGUF / Ollama 模型。注册后可在对话中切换到本地模型,减少云端算力。", + "localHardwareHint": "从这里开始:已检测内存、CPU、显卡(如有)、Ollama 以及本机已有的 GGUF / Ollama 模型。注册后即可在对话中使用本机模型。云厂商仍是可选项。", "localCpu": "CPU 核心", "localRam": "内存", "localGpu": "显卡", @@ -3363,6 +3365,7 @@ "nameLabel": "名称", "namePlaceholder": "例如:my-openai", "baseUrlExtra": "OpenAI 兼容 endpoint 的 base URL,留空则使用官方默认。", + "baseUrlExtraLocal": "优先填写本机 OpenAI 兼容地址,例如 Ollama(http://127.0.0.1:11434/v1)、LM Studio(:1234/v1)或 llama.cpp(:8080/v1)。云端地址仍可用。", "defaultModelLabel": "默认模型", "defaultModelPlaceholder": "例如:gpt-4o-mini", "noteLabel": "备注(可选)", @@ -4859,7 +4862,7 @@ "intro": "创建第一个管理员账户。" }, "model": { - "intro": "配置一个模型提供商,助手就可以回应你了。你也可以跳过本步,稍后在「模型」页配置。", + "intro": "先选本机运行时(如 Ollama)或本机 OpenAI 兼容地址。云厂商是可选项。也可以跳过,稍后在「模型」页完成。", "skip": "跳过", "skipToWorkspace": "跳过,进入工作台", "presetTab": "预置提供商", @@ -4867,7 +4870,10 @@ "showMorePresets": "显示更多提供商({{count}} 个)", "hideMorePresets": "收起", "choosePreset": "选择预置提供商", - "choosePresetHint": "点击卡片快速填入配置,只需填写 API Key。同品牌多站点会先弹出站点选择。", + "choosePresetHint": "默认先选 Ollama,让对话留在这台电脑上。需要云厂商时再点对应卡片并填写 API Key。", + "localRecommended": "这台电脑", + "localOptionalTest": "若 Ollama 或其他本机运行时已启动,可以先测连通性。也可以直接继续,稍后在「模型 → 本地」拉取模型。", + "continueLocal": "按本机配置继续", "siteCount": "{{count}} 个站点", "customSetup": "自定义配置", "testButton": "测试连通性", @@ -5807,7 +5813,7 @@ }, "models": { "title": "模型配置", - "subtitle": "配置对话模型、生成模型、语音模型与搜索引擎" + "subtitle": "优先使用这台电脑上的模型。云端、生成、语音和搜索仍是可选项。" }, "voice": { "title": "语音服务", diff --git a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx index c9818c73..703f2449 100644 --- a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx +++ b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx @@ -24,6 +24,12 @@ describe("", () => { ); expect(screen.getByText("knowledgeBases.homeTitle")).toBeInTheDocument(); + expect( + screen.getByText("knowledgeBases.homeRecommended"), + ).toBeInTheDocument(); + expect( + screen.getByText("knowledgeBases.homeOptionalCloud"), + ).toBeInTheDocument(); expect(screen.getByText("knowledgeBases.mountTitle")).toBeInTheDocument(); expect( screen.getByText("knowledgeBases.cloudMountTitle"), diff --git a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx index af0bf1b3..5ea49f95 100644 --- a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx +++ b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx @@ -32,10 +32,13 @@ export function KnowledgeMountHome({ {t("knowledgeBases.homeDesc")}
-
+
+ + {t("knowledgeBases.homeRecommended")} + ensureKb(t("knowledgeBases.defaultLocalName"))} @@ -46,6 +49,9 @@ export function KnowledgeMountHome({
+ + {t("knowledgeBases.homeOptionalCloud")} + ensureKb(t("knowledgeBases.defaultCloudName"))} diff --git a/dashboard/src/pages/KnowledgeBases/index.module.less b/dashboard/src/pages/KnowledgeBases/index.module.less index 45bf1e1a..dc72cb3a 100644 --- a/dashboard/src/pages/KnowledgeBases/index.module.less +++ b/dashboard/src/pages/KnowledgeBases/index.module.less @@ -831,6 +831,21 @@ background: var(--fn-bg-primary); } +.mountCardPreferred { + border-color: color-mix( + in srgb, + var(--fn-color-brand) 45%, + var(--fn-border-secondary) + ); + box-shadow: 0 0 0 1px + color-mix(in srgb, var(--fn-color-brand) 18%, transparent); +} + +.mountCardBadge { + font-size: 12px; + font-weight: 600; +} + .mountCardIcon { display: inline-flex; align-items: center; diff --git a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx index ab92c08b..b0673971 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx @@ -11,6 +11,7 @@ import { request } from "../../../../../api/request"; import type { ProviderModel, ProviderRow } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; import { fetchProviderModels, testProviderDraft } from "../../providerApi"; +import { isLocalBaseUrl, localPlaceholderApiKey } from "../../presetUtils"; import { ModelListEditor } from "./ModelListEditor"; import styles from "../../index.module.less"; @@ -50,7 +51,8 @@ export function CustomProviderModal({ const baseUrl = Form.useWatch("base_url", form) as string | undefined; const apiKey = Form.useWatch("api_key", form) as string | undefined; const [models, setModels] = useState([]); - const canTest = !!apiKey?.trim(); + const localEndpoint = isLocalBaseUrl(baseUrl); + const canTest = !!apiKey?.trim() || localEndpoint; const draftProvider = useMemo( () => ({ @@ -81,7 +83,10 @@ export function CustomProviderModal({ "api_key", "base_url", ]); - const key = (values.api_key as string | undefined)?.trim(); + const key = localPlaceholderApiKey( + values.base_url, + values.api_key as string | undefined, + ); if (!key) { return { ok: false, error: t("models.pleaseEnterApiKey") }; } @@ -102,7 +107,10 @@ export function CustomProviderModal({ message.warning(t("models.fetchModelsUnsupportedKind")); return; } - const apiKey = (values.api_key as string | undefined)?.trim(); + const apiKey = localPlaceholderApiKey( + values.base_url as string | undefined, + values.api_key as string | undefined, + ); if (!apiKey) { message.warning(t("models.pleaseEnterApiKey")); return; @@ -181,7 +189,11 @@ export function CustomProviderModal({ name: (values.name as string).trim(), kind: values.kind as string, base_url: (values.base_url as string | undefined)?.trim() || null, - api_key: (values.api_key as string | undefined)?.trim() || null, + api_key: + localPlaceholderApiKey( + values.base_url as string | undefined, + values.api_key as string | undefined, + ) || null, models: modelEntries.length > 0 ? modelEntries : [], note: (values.note as string | undefined)?.trim() || null, }), @@ -251,8 +263,8 @@ export function CustomProviderModal({ k.value === "openai" ? t("models.kindOpenaiCompat") : k.value === "anthropic" - ? "Anthropic" - : "AWS Bedrock", + ? "Anthropic" + : "AWS Bedrock", }))} /> @@ -260,13 +272,22 @@ export function CustomProviderModal({ - + - - + + {kind === "openai" && ( diff --git a/dashboard/src/pages/Settings/Models/index.tsx b/dashboard/src/pages/Settings/Models/index.tsx index 9abbc945..a7c9a64a 100644 --- a/dashboard/src/pages/Settings/Models/index.tsx +++ b/dashboard/src/pages/Settings/Models/index.tsx @@ -25,7 +25,12 @@ import PageShell from "../../../layouts/PageShell"; import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { userCan } from "../../../utils/permissions"; import { useProviders, type ProviderRow } from "./useProviders"; -import { groupPresets, isLocalPreset, isPresetProvider } from "./presetUtils"; +import { + defaultModelsPresetTab, + groupPresets, + isLocalPreset, + isPresetProvider, +} from "./presetUtils"; import type { PresetGroup } from "./presetUtils"; import type { ProviderPreset } from "./useProviders"; import { @@ -124,6 +129,10 @@ export default function ModelsPage() { (canOllama || canOnnx) && (localPresets.grouped.length > 0 || localPresets.ungrouped.length > 0); const showPresetSection = showCloudTab || showLocalTab; + const defaultPresetTab = defaultModelsPresetTab({ + showLocal: showLocalTab, + showCloud: showCloudTab, + }); const renderPresetGrid = ( grouped: PresetGroup[], @@ -284,17 +293,8 @@ export default function ModelsPage() { {t("models.presetProviders")} item !== null)} /> diff --git a/dashboard/src/pages/Settings/Models/presetUtils.test.ts b/dashboard/src/pages/Settings/Models/presetUtils.test.ts index e2e4d98c..fa3c25b5 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.test.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { findConfiguredProvider } from "./presetUtils"; +import { + buildWizardPresetDisplay, + defaultModelsPresetTab, + defaultWizardPreset, + findConfiguredProvider, + isLocalBaseUrl, + localPlaceholderApiKey, +} from "./presetUtils"; import type { ProviderPreset, ProviderRow } from "./useProviders"; const ollamaPreset = { @@ -7,6 +14,18 @@ const ollamaPreset = { name: "Ollama (Local)", } as ProviderPreset; +function preset( + partial: Partial & Pick, +): ProviderPreset { + return { + base_url: "", + protocol: "openai", + api_key_prefix: "", + models: [], + ...partial, + }; +} + function row( partial: Partial & Pick, ): ProviderRow { @@ -39,3 +58,46 @@ describe("findConfiguredProvider", () => { expect(findConfiguredProvider(ollamaPreset, [local])).toEqual(local); }); }); + +describe("local-first wizard and settings helpers", () => { + const ollama = preset({ id: "ollama", name: "Ollama (Local)" }); + const onnx = preset({ id: "onnx", name: "ONNX (Local)" }); + const openai = preset({ id: "openai", name: "OpenAI" }); + const deepseek = preset({ id: "deepseek", name: "DeepSeek" }); + + it("defaults the wizard to Ollama when present", () => { + expect(defaultWizardPreset([openai, ollama, onnx])).toEqual(ollama); + }); + + it("features Ollama before cloud brands and keeps ONNX in more", () => { + const { featured, more } = buildWizardPresetDisplay([ + openai, + deepseek, + ollama, + onnx, + ]); + expect(featured[0]).toEqual({ kind: "single", preset: ollama }); + expect( + more.some((item) => item.kind === "single" && item.preset.id === "onnx"), + ).toBe(true); + }); + + it("defaults the models preset tab to local when that tab exists", () => { + expect(defaultModelsPresetTab({ showLocal: true, showCloud: true })).toBe( + "local", + ); + expect(defaultModelsPresetTab({ showLocal: false, showCloud: true })).toBe( + "cloud", + ); + }); + + it("treats loopback OpenAI-compatible URLs as local and fills a placeholder key", () => { + expect(isLocalBaseUrl("http://127.0.0.1:1234/v1")).toBe(true); + expect(isLocalBaseUrl("http://localhost:8080/v1")).toBe(true); + expect(isLocalBaseUrl("https://api.openai.com/v1")).toBe(false); + expect(localPlaceholderApiKey("http://127.0.0.1:11434/v1", "")).toBe( + "local", + ); + expect(localPlaceholderApiKey("https://api.openai.com/v1", "")).toBe(""); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/presetUtils.ts b/dashboard/src/pages/Settings/Models/presetUtils.ts index 359b62be..b2a89193 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.ts @@ -114,7 +114,12 @@ export function isPresetProvider( const LOCAL_PRESET_IDS = new Set(["ollama", "onnx"]); -export function isLocalPreset(preset: ProviderPreset): boolean { +/** Chat-capable local runtime shown first in the wizard (ONNX is embedding-only). */ +export function isLocalChatPreset(preset: Pick): boolean { + return preset.id === "ollama"; +} + +export function isLocalPreset(preset: Pick): boolean { return LOCAL_PRESET_IDS.has(preset.id); } @@ -123,6 +128,85 @@ export function isLocalNoKeyPresetId(presetId: string): boolean { return presetId === "ollama" || presetId === "onnx"; } +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]); +const LOCAL_SERVICE_HOSTS = new Set(["ollama", "lmstudio", "vllm"]); + +/** True when the URL is a loopback or well-known local OpenAI-compatible host. */ +export function isLocalBaseUrl(url?: string | null): boolean { + const raw = (url ?? "").trim(); + if (!raw) return false; + try { + const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`); + const host = parsed.hostname.toLowerCase(); + if (LOOPBACK_HOSTS.has(host) || LOCAL_SERVICE_HOSTS.has(host)) return true; + return host.endsWith(".local"); + } catch { + return /localhost|127\.0\.0\.1/.test(raw); + } +} + +export function localPlaceholderApiKey( + url?: string | null, + existing?: string | null, +): string { + const key = (existing ?? "").trim(); + if (key) return key; + return isLocalBaseUrl(url) ? "local" : ""; +} + +export function defaultWizardPreset( + presets: ProviderPreset[], +): ProviderPreset | undefined { + return ( + presets.find((p) => p.id === "ollama") ?? + presets.find((p) => isLocalChatPreset(p)) ?? + presets[0] + ); +} + +export type WizardPresetDisplayItem = + | { kind: "single"; preset: ProviderPreset } + | { kind: "group"; group: PresetGroup }; + +const WIZARD_FEATURED_COUNT = 6; + +/** Local chat (Ollama) first, then a few cloud brands; remaining cloud + ONNX in more. */ +export function buildWizardPresetDisplay(presets: ProviderPreset[]): { + featured: WizardPresetDisplayItem[]; + more: WizardPresetDisplayItem[]; +} { + const localChat = presets.filter((p) => isLocalChatPreset(p)); + const rest = presets.filter((p) => !isLocalChatPreset(p)); + const { grouped, ungrouped } = groupPresets(rest); + const restItems: WizardPresetDisplayItem[] = [ + ...grouped.map((group): WizardPresetDisplayItem => ({ + kind: "group", + group, + })), + ...ungrouped.map((preset): WizardPresetDisplayItem => ({ + kind: "single", + preset, + })), + ]; + const localItems: WizardPresetDisplayItem[] = localChat.map((preset) => ({ + kind: "single", + preset, + })); + const remainingSlots = Math.max(WIZARD_FEATURED_COUNT - localItems.length, 0); + return { + featured: [...localItems, ...restItems.slice(0, remainingSlots)], + more: restItems.slice(remainingSlots), + }; +} + +export function defaultModelsPresetTab(opts: { + showLocal: boolean; + showCloud: boolean; +}): "local" | "cloud" { + if (opts.showLocal) return "local"; + return "cloud"; +} + /** Stable placeholder api_key written when creating a local preset row. */ function localPresetApiKey(provider: { api_key?: string | null }): string { return (provider.api_key ?? "").trim().toLowerCase(); diff --git a/dashboard/src/pages/Setup/steps/ModelStep.tsx b/dashboard/src/pages/Setup/steps/ModelStep.tsx index 2e52d29f..8e63e9fd 100644 --- a/dashboard/src/pages/Setup/steps/ModelStep.tsx +++ b/dashboard/src/pages/Setup/steps/ModelStep.tsx @@ -32,8 +32,11 @@ import modelStyles from "../../Settings/Models/index.module.less"; import setupStyles from "../setup.module.less"; import { enrichWizardModel } from "../../Settings/Models/wizardModelMeta"; import { - groupPresets, - isLocalPreset, + buildWizardPresetDisplay, + defaultWizardPreset, + isLocalBaseUrl, + isLocalChatPreset, + localPlaceholderApiKey, presetLogoId, presetVariantLabel, type PresetGroup, @@ -103,37 +106,8 @@ interface Props { type SetupMode = "preset" | "custom"; -/** Match admin/models Cloud tab order (grouped brands, then singles); show this many by default. */ -const WIZARD_FEATURED_COUNT = 6; - -function buildWizardPresetDisplay(presets: ProviderPreset[]): { - featured: PresetDisplayItem[]; - more: PresetDisplayItem[]; -} { - const cloud = presets.filter((p) => !isLocalPreset(p as AdminProviderPreset)); - const local = presets.filter((p) => isLocalPreset(p as AdminProviderPreset)); - const { grouped, ungrouped } = groupPresets(cloud as AdminProviderPreset[]); - const ordered: PresetDisplayItem[] = [ - ...grouped.map((group): PresetDisplayItem => ({ kind: "group", group })), - ...ungrouped.map( - (preset): PresetDisplayItem => ({ - kind: "single", - preset: preset as ProviderPreset, - }), - ), - ]; - const featured = ordered.slice(0, WIZARD_FEATURED_COUNT); - const more: PresetDisplayItem[] = [ - ...ordered.slice(WIZARD_FEATURED_COUNT), - ...local.map((preset): PresetDisplayItem => ({ kind: "single", preset })), - ]; - return { featured, more }; -} - -function defaultPresetFromItem(item: PresetDisplayItem): ProviderPreset { - return item.kind === "group" - ? (item.group.presets[0] as ProviderPreset) - : item.preset; +function wizardDisplay(presets: ProviderPreset[]) { + return buildWizardPresetDisplay(presets as AdminProviderPreset[]); } const CUSTOM_KINDS = [ @@ -198,10 +172,8 @@ export default function ModelStep({ .then((data) => { if (!cancelled && Array.isArray(data) && data.length > 0) { setPresets(data); - const { featured } = buildWizardPresetDisplay(data); const initial = - (featured[0] ? defaultPresetFromItem(featured[0]) : null) ?? - data[0]; + defaultWizardPreset(data as AdminProviderPreset[]) ?? data[0]; setSelectedPresetId(initial.id); const modelIds = initial.models.map((m) => m.id); setSelectedModelIds(modelIds); @@ -241,8 +213,11 @@ export default function ModelStep({ ); const { featuredDisplayItems, moreDisplayItems } = useMemo(() => { - const { featured, more } = buildWizardPresetDisplay(presets); - return { featuredDisplayItems: featured, moreDisplayItems: more }; + const { featured, more } = wizardDisplay(presets); + return { + featuredDisplayItems: featured as PresetDisplayItem[], + moreDisplayItems: more as PresetDisplayItem[], + }; }, [presets]); const visibleDisplayItems = showAllPresets @@ -255,6 +230,11 @@ export default function ModelStep({ ); const isOllama = preset?.id === "ollama"; + const customBaseUrl = Form.useWatch("base_url", customForm) as + string | undefined; + const customIsLocal = isLocalBaseUrl(customBaseUrl); + const canContinueWithoutTest = + (mode === "preset" && isOllama) || (mode === "custom" && customIsLocal); const applyPreset = (p: ProviderPreset) => { resetTest(); @@ -341,7 +321,7 @@ export default function ModelStep({ field: "api_key", }; } - if (selectedModelIds.length === 0) { + if (selectedModelIds.length === 0 && !isOllama) { return { ok: false, message: t("models.addModelFirst") }; } @@ -369,7 +349,7 @@ export default function ModelStep({ } } - if (modelEntries.length === 0) { + if (modelEntries.length === 0 && !isOllama) { return { ok: false, message: t("models.addModelFirst") }; } @@ -409,10 +389,11 @@ export default function ModelStep({ field: "kind", }; } - if (customModels.length === 0) { + if (customModels.length === 0 && !isLocalBaseUrl(baseUrl)) { return { ok: false, message: t("models.addModelFirst") }; } - if (!apiKey) { + const resolvedKey = localPlaceholderApiKey(baseUrl, apiKey); + if (!resolvedKey) { return { ok: false, message: t("models.pleaseEnterApiKey"), @@ -425,7 +406,7 @@ export default function ModelStep({ draft: { name, type: kind, - api_key: apiKey, + api_key: resolvedKey, base_url: baseUrl, models: buildWizardModels(customModels), }, @@ -551,7 +532,7 @@ export default function ModelStep({ }; const handleContinue = async () => { - if (!testPassed) { + if (!testPassed && !canContinueWithoutTest) { message.warning(t("wizard.model.testFirstHint")); return; } @@ -583,10 +564,12 @@ export default function ModelStep({
@@ -863,7 +846,17 @@ export default function ModelStep({ /> )}
-
{p.name}
+
+ {p.name} + {isLocalChatPreset(p as AdminProviderPreset) ? ( + + {t("wizard.model.localRecommended")} + + ) : null} +
{t("models.modelsCount", { count: p.models.length })}
@@ -1109,8 +1102,8 @@ export default function ModelStep({ k.value === "openai" ? t("models.kindOpenaiCompat") : k.value === "anthropic" - ? "Anthropic" - : "AWS Bedrock", + ? "Anthropic" + : "AWS Bedrock", }))} /> @@ -1118,20 +1111,30 @@ export default function ModelStep({ - + typeof e === "string" ? e : String(e?.target?.value ?? "") } > - + @@ -1260,6 +1263,14 @@ export default function ModelStep({ {t("wizard.model.intro")} + {canContinueWithoutTest && ( + + {t("wizard.model.localOptionalTest")} + + )}
diff --git a/desktop/README.md b/desktop/README.md index 9d40d732..40781c1f 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -36,6 +36,10 @@ Same precedence as the FreeOS CLI/server: FreeOS-native page (`/api/org-module/*`), not an iframe of 3780. - Shell prefs → `{home}/desktop-settings.json` +## Local models and knowledge + +First launch opens the model wizard with **Ollama first**. Skip is fine; **Models → Local** can start Ollama, register GGUF, or add a local OpenAI-compatible URL. **Knowledge Bases** mounts a folder on this computer; local ONNX is the default embedding path. Cloud vendors and ima stay optional. See [docs/user-guide.md](../docs/user-guide.md) and [docs/configuration.md](../docs/configuration.md#local-models-and-knowledge-bases). + ## Windows install finish The NSIS finish page offers **运行 FreeOS** / **Run FreeOS**, checked by diff --git a/docs/configuration.md b/docs/configuration.md index 20ea1a18..b64a19e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -207,8 +207,10 @@ step; password verification works without a pool. The modern flow uses SQLite or PostgreSQL, probe, persist `config.json`, bind pool + migrate. 3. `POST /api/setup/initial-admin` — creates the seed admin (requires DB). 4. `POST /api/setup/test-provider` — pings an optional provider draft. + Local Ollama / loopback OpenAI-compatible URLs do not need a vendor key + (a placeholder is filled). 5. `POST /api/setup/finish` — bootstraps default `main` agent and unlocks - the rest of the API. + the rest of the API. The dashboard wizard lists **Ollama first**. `GET /api/setup/status` returns `setup_required`, wizard password fields, plus `database_bound` / `database_driver`. `setup_lockdown` middleware @@ -219,6 +221,18 @@ For unattended installs, use `octop init --yes` with `OCTOP_REQUIRE_SETUP_PASSWORD=false` if the env-var path is used). This runs the same migrations + admin creation without the HTTP wizard. +## Local models and knowledge bases + +Prefer runtimes and files on the operator's machine. Cloud vendors stay optional. + +| Surface | Default path | +|---|---| +| Chat | Wizard / **Models → Local**: Ollama (`http://127.0.0.1:11434/v1`), GGUF register, or a custom local OpenAI-compatible URL (LM Studio `:1234`, llama.cpp `:8080`). | +| Embeddings / RAG | Knowledge base foundation settings default to **local ONNX**. Optional extra: `uv sync --extra local-embedding`. | +| Knowledge files | Sidebar **Knowledge Bases**: mount a local folder. ima / WeKnora are optional connectors. Desktop first-run enables the feature (`knowledge_bases_enabled`). | + +Related: [user-guide.md](user-guide.md), [product-contract.md](product-contract.md#local-models-and-knowledge-bases). + ## Secrets The JWT secret is generated on first start and stored in diff --git a/docs/org-capability-migration-map.json b/docs/org-capability-migration-map.json index 368e5d72..83e69164 100644 --- a/docs/org-capability-migration-map.json +++ b/docs/org-capability-migration-map.json @@ -264,9 +264,11 @@ "name": "Local models", "name_zh": "本地模型", "lives_today": [ - "dashboard/src/pages/Settings/Models (Ollama / GGUF register, speed test, set default)", + "dashboard/src/pages/Setup/steps/ModelStep.tsx (Ollama first)", + "dashboard/src/pages/Settings/Models (Local tab first; Ollama / GGUF register, speed test, set default)", "src/octop/infra/agents/providers/local_register.py", "src/octop/infra/agents/providers/onnx_download.py", + "src/octop/infra/utils/local_endpoint.py", "modules/openxyos/frontend/src/pages/SettingsPage.tsx + /api/settings/ai (Node, unmigrated)" ], "status": "native_host", @@ -275,8 +277,16 @@ "priority": "P0", "next_step": "Make the organization room consume the same local model pool as the studio so org LLM settings do not stay on Node /api/settings/ai.", "wave": "D-local-models-kb", - "evidence": ["CHANGELOG Unreleased local model speed/default", "docs/user-guide.md Ollama section (upstream host)"], - "tests": [], + "evidence": [ + "CHANGELOG Unreleased P1.4 local-first wizard/settings", + "docs/user-guide.md local models section", + "docs/configuration.md#local-models-and-knowledge-bases" + ], + "tests": [ + "dashboard/src/pages/Settings/Models/presetUtils.test.ts", + "tests/unit/utils/test_local_endpoint.py", + "tests/unit/test_provider_preset_expansion.py" + ], "unknowns": [ "No dedicated org-module tests assert that organization pages call host local providers instead of sidecar AI settings." ] @@ -287,6 +297,7 @@ "name_zh": "宿主知识库", "lives_today": [ "dashboard/src/pages/KnowledgeBases", + "dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx (local folder first)", "dashboard/src/routes/index.tsx /knowledge-bases", "Octop KnowledgeService / knowledge_bases tables" ], @@ -296,10 +307,17 @@ "priority": "P0", "next_step": "Keep local folder mounts and embeddings as the default RAG path; do not require WeKnora/IMA cloud for organization knowledge.", "wave": "D-local-models-kb", - "evidence": ["src/octop/infra/server.py desktop first-run enables knowledge bases"], - "tests": [], + "evidence": [ + "src/octop/infra/server.py desktop first-run enables knowledge bases", + "KnowledgeMountHome local-first empty state", + "docs/user-guide.md §4.8" + ], + "tests": [ + "dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx", + "tests/unit/knowledge/test_gate.py" + ], "unknowns": [ - "Connector adapters for WeKnora/IMA exist; default-path policy vs optional cloud is product, not fully encoded in org docs on main." + "Connector adapters for WeKnora/IMA exist; cloud remains optional." ] }, { diff --git a/docs/org-capability-migration-map.md b/docs/org-capability-migration-map.md index 6d0e11f6..35b62d70 100644 --- a/docs/org-capability-migration-map.md +++ b/docs/org-capability-migration-map.md @@ -146,26 +146,27 @@ These shipping shapes still need a native port. They are bridges. | | | |---|---| -| **Lives today** | Dashboard Models (Ollama/GGUF, speed test, set default); `infra/agents/providers/local_register.py`. Org Settings AI still Node `/api/settings/ai`. | +| **Lives today** | Dashboard wizard (Ollama first) and Models → Local (Ollama/GGUF, speed test, set default); `infra/agents/providers/local_register.py`. Org Settings AI still Node `/api/settings/ai`. | | **Status** | `native_host` (studio) | | **Depends on Node?** | partial | | **Commercial export?** | yes | | **Priority** | P0 · Wave D | | **Next step** | Organization room consumes the same local pool; do not leave org LLM on sidecar settings. | -| **Tests** | (none org-module-specific found) | +| **Tests** | `dashboard/src/pages/Settings/Models/presetUtils.test.ts`; `tests/unit/utils/test_local_endpoint.py`; `tests/unit/test_provider_preset_expansion.py` | | **Unknown** | No test asserts org pages call host local providers. | ### 5. Host knowledge bases | | | |---|---| -| **Lives today** | `/knowledge-bases`, Octop `KnowledgeService` | +| **Lives today** | `/knowledge-bases`, Octop `KnowledgeService`. Empty state leads with a local folder mount; local ONNX is the default embedding backend. | | **Status** | `native_host` | | **Depends on Node?** | no | | **Commercial export?** | partial | | **Priority** | P0 · Wave D | | **Next step** | Local folder mounts/embeddings stay default; WeKnora/IMA remain optional connectors. | -| **Unknown** | Default-path policy vs optional cloud is not fully encoded in org docs on main. | +| **Tests** | `KnowledgeMountHome.test.tsx`; `tests/unit/knowledge/test_gate.py` (desktop default + ONNX backend) | +| **Unknown** | Connector adapters for WeKnora/IMA exist; cloud remains optional. | ### 6. Organization knowledge (notes/files) diff --git a/docs/org-capability-migration-map.zh-CN.md b/docs/org-capability-migration-map.zh-CN.md index b7ce8cf3..343a2979 100644 --- a/docs/org-capability-migration-map.zh-CN.md +++ b/docs/org-capability-migration-map.zh-CN.md @@ -146,26 +146,27 @@ | | | |---|---| -| **今日位置** | Dashboard 模型页(Ollama/GGUF、测速、设为默认);`infra/agents/providers/local_register.py`。组织设置里的 AI 仍是 Node `/api/settings/ai`。 | +| **今日位置** | 设置向导默认先选 Ollama;Dashboard 模型页「本地」标签(Ollama/GGUF、测速、设为默认);`infra/agents/providers/local_register.py`。组织设置里的 AI 仍是 Node `/api/settings/ai`。 | | **状态** | `native_host`(工作室) | | **依赖 Node?** | 部分 | | **商业导出?** | 是 | | **优先级** | P0 · 波次 D | | **下一步** | 组织房间使用同一套本机模型池;不要把组织 LLM 留在边车设置。 | -| **测试** | (未找到 org-module 专项) | +| **测试** | `dashboard/src/pages/Settings/Models/presetUtils.test.ts`;`tests/unit/utils/test_local_endpoint.py` | | **未知** | 没有测试断言组织页调用宿主本地供应商。 | ### 5. 宿主知识库 | | | |---|---| -| **今日位置** | `/knowledge-bases`,Octop `KnowledgeService` | +| **今日位置** | `/knowledge-bases`,Octop `KnowledgeService`。空状态先引导本机文件夹挂接;向量默认本机 ONNX。 | | **状态** | `native_host` | | **依赖 Node?** | 否 | | **商业导出?** | 部分 | | **优先级** | P0 · 波次 D | | **下一步** | 本地文件夹挂接/嵌入保持默认;WeKnora/IMA 仍是可选连接器。 | -| **未知** | 默认路径 vs 可选云,尚未完整写进 main 上的组织文档。 | +| **测试** | `KnowledgeMountHome.test.tsx`;`tests/unit/knowledge/test_gate.py` | +| **未知** | WeKnora/IMA 连接器仍在;云端保持可选。 | ### 6. 组织知识(笔记/文件) diff --git a/docs/user-guide.md b/docs/user-guide.md index 66794cb2..e489df89 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -20,13 +20,14 @@ - [3.2 向导步骤说明](#32-向导步骤说明) - [3.3 无人值守 / 跳过向导](#33-无人值守--跳过向导) - [四、配置模型(LLM 供应商)](#四配置模型llm-供应商) - - [4.1 预设供应商](#41-预设供应商) - - [4.2 自定义供应商](#42-自定义供应商) - - [4.3 选择模型并测试连接](#43-选择模型并测试连接) - - [4.4 在控制台中管理供应商](#44-在控制台中管理供应商) - - [4.5 通过 CLI 配置供应商](#45-通过-cli-配置供应商) - - [4.6 本地模型 Ollama](#46-本地模型-ollama) + - [4.1 本机模型(推荐)](#41-本机模型推荐) + - [4.2 本机 OpenAI 兼容端点](#42-本机-openai-兼容端点) + - [4.3 云厂商与自定义供应商](#43-云厂商与自定义供应商) + - [4.4 选择模型并测试连接](#44-选择模型并测试连接) + - [4.5 在控制台中管理供应商](#45-在控制台中管理供应商) + - [4.6 通过 CLI 配置供应商](#46-通过-cli-配置供应商) - [4.7 图片与视频生成模型](#47-图片与视频生成模型) + - [4.8 知识库(本机优先)](#48-知识库本机优先) - [五、基本使用](#五基本使用) - [5.1 登录](#51-登录) - [5.2 对话(Chat)](#52-对话chat) @@ -190,10 +191,10 @@ octop service start # Linux(systemd) / macOS(launchd) / Windows 服务 **步骤 4:配置模型(LLM 供应商)** -- 选择预设供应商(如 OpenAI、DeepSeek、Ollama 等)或自定义供应商。 -- 填写 API Key、Base URL,勾选要启用的模型。 -- 点击 **测试连接**,通过后点击 **继续**。 -- 该步骤可**跳过**(Skip),稍后在控制台「设置 → 模型 / 供应商」中再配置。 +- 向导默认先选 **Ollama(本机)**。本机运行时已启动时可测连通性后继续;也可以先继续,稍后在「模型 → 本地」拉取模型。 +- 云厂商卡片仍在「显示更多」里,需要时再填 API Key。 +- 自定义供应商可填本机 OpenAI 兼容地址(LM Studio、llama.cpp、vLLM),本机地址不必填真实密钥。 +- 该步骤可**跳过**(Skip),稍后在控制台「模型」中再配置。 详见下一节 [四、配置模型](#四配置模型llm-供应商)。 @@ -222,11 +223,30 @@ octop run ## 四、配置模型(LLM 供应商) -Octop 通过 **供应商(Provider)** 接入大模型。每个 Agent 可使用不同的供应商与模型。支持 OpenAI 兼容 API、Anthropic、AWS Bedrock、DashScope(通义千问)、Ollama 本地模型等。 +FreeOS 通过 **供应商(Provider)** 接入大模型。默认路径是本机运行时;云厂商是可选项。每个 Agent 可使用不同的供应商与模型。 -### 4.1 预设供应商 +### 4.1 本机模型(推荐) -在向导"模型"步骤或控制台"设置 → 模型"中,可一键选择常见预设: +在向导「模型」步骤或控制台 **模型 → 本地**: + +1. 若已安装 Ollama,可一键启动(Windows 走已知安装路径)。未安装时,按页面提示用官方安装方式补齐。 +2. 拉取或注册本机模型(Ollama tag,或扫描到的 GGUF / GGML)。 +3. 「测速」确认本机响应;「设为默认」后,新对话优先使用它。 +4. 本机 ONNX 出现在同一「本地」页,用于知识库向量,不用于对话。 + +Ollama 预设默认 `base_url` 为 `http://127.0.0.1:11434/v1`,无需云厂商 API Key。 + +### 4.2 本机 OpenAI 兼容端点 + +LM Studio、llama.cpp server、vLLM 等只要提供 OpenAI 兼容 `/v1`,即可在向导「自定义」或 **模型 → 自定义提供商** 里接入: + +- **类型**:`openai`(OpenAI 兼容) +- **Base URL**:如 `http://127.0.0.1:1234/v1`(LM Studio)、`:8080/v1`(llama.cpp) +- **API Key**:本机地址可留空(会写入占位值) + +### 4.3 云厂商与自定义供应商 + +需要云厂商时,在向导或 **模型 → 云端** 选择预设: | 预设 | 说明 | |------|------| @@ -235,51 +255,37 @@ Octop 通过 **供应商(Provider)** 接入大模型。每个 Agent 可使 | DeepSeek | 需 API Key | | 智谱 Zhipu | 通义 / 智谱 GLM,需 API Key | | Kimi | 月之暗面,需 API Key | -| Ollama | 本地模型,默认无需 API Key | - -选择预设后会自动带出该供应商的默认 `base_url` 与内置模型列表。 - -### 4.2 自定义供应商 -当所需服务不在预设中时(如自建 OpenAI 兼容网关、Azure OpenAI、第三方中转),可选择"自定义": - -- **类型(kind)**:`openai`(OpenAI 兼容)、`anthropic`、`bedrock`。 -- **名称**:自定义显示名。 -- **Base URL**:API 接入地址(如 `https://api.openai.com/v1`)。 -- **API Key**:服务商提供的密钥。 +选择预设后会自动带出该供应商的默认 `base_url` 与内置模型列表。非本机自定义网关同样走「自定义」,并填写真实 API Key。 ![图 4.1 — 自定义供应商与模型选择](assets/model-01-custom.png) -### 4.3 选择模型并测试连接 - -1. 在供应商下勾选要启用的模型(可全选 / 全不选)。 -2. 点击 **测试连接**,系统会向供应商发送一次探测请求并显示延迟。 -3. 测试通过后点击 **继续 / 保存**。 +### 4.4 选择模型并测试连接 -> 若测试失败,请检查 API Key、Base URL、网络连通性与账户配额。 +1. 在供应商下勾选要启用的模型(可全选 / 全不选)。本机 Ollama 若尚未拉取模型,可先继续,稍后再注册。 +2. 云厂商需 **测试连接** 通过后再继续;本机路径可以先继续,运行时起来后再测。 +3. 若测试失败,请检查本机端口(11434 / 1234 / 8080)、或云厂商的 API Key、Base URL 与配额。 -### 4.4 在控制台中管理供应商 +### 4.5 在控制台中管理供应商 -除首次向导外,日常可在 **设置 → 模型 / 供应商** 中: +除首次向导外,日常可在 **模型** 页(默认打开「本地」标签)中: -- 新增 / 编辑 / 删除供应商。 +- 查看本机硬件、启动 Ollama、注册 GGUF。 +- 新增 / 编辑 / 删除云厂商或自定义供应商。 - 为一个供应商增删模型(含自定义模型 ID、上下文窗口、最大 Token、是否支持推理)。 - 为不同 Agent 指定默认供应商与模型。 ![图 4.2 — 控制台模型管理](assets/model-02-manage.png) -### 4.5 通过 CLI 配置供应商 +### 4.6 通过 CLI 配置供应商 ```bash -octop models # 查看供应商预设与模型解析 +octop models # 查看供应商预设与模型解析(Ollama / ONNX 排在前面) octop provider list # 列出已配置供应商 octop provider --help # 供应商增删改查帮助 +octop models ollama-list # 列出本机 Ollama 模型(需服务已启动) ``` -### 4.6 本地模型 Ollama - -若本机已运行 Ollama,可选择 `ollama` 预设(默认 `base_url` 为本地地址),无需 API Key 即可接入本地模型,适合隐私敏感或离线场景。 - ### 4.7 图片与视频生成模型 具备云模型管理权限的用户可以打开 **设置 → 模型 → 生成模型**,为所有 Agent @@ -293,6 +299,17 @@ octop provider --help # 供应商增删改查帮助 > 模型测试会向火山方舟提交真实生成请求,可能产生少量费用。视频测试在任务创建成功后会立即请求取消。 +### 4.8 知识库(本机优先) + +侧栏 **知识库** 把文档做成对话可检索的资料。默认路径是这台电脑上的文件夹,不必先接云。 + +1. 桌面首次启动会打开知识库功能。空状态先引导 **挂接本机文件夹**(只读;解析结果写到你另选的目录)。 +2. **基础设置** 里向量模型默认 **本机 ONNX**(需先下载模型;可选 `uv sync --extra local-embedding`)。在线 Embedding 是可选项。 +3. ima / WeKnora 等云挂接是可选项,出现在本机卡片旁边并标为「可选」。 +4. 组织房间的知识页列出的是同一套宿主知识库,不是第二套笔记库。 + +配置指针:[configuration.md](configuration.md#local-models-and-knowledge-bases)。 + --- ## 五、基本使用 diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index c9c49634..6e6ab633 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -12,7 +12,10 @@ from pydantic import BaseModel from octop.api.deps import current_user, get_server, require_permission -from octop.infra.agents.providers.model_flags import is_local_runtime_provider +from octop.infra.agents.providers.model_flags import ( + is_local_runtime_provider, + resolve_provider_credentials, +) from octop.infra.agents.providers.presets import load_provider_presets from octop.infra.agents.providers.probe import ( fetch_openai_compatible_models, @@ -302,7 +305,11 @@ async def admin_test_provider_draft( _: Any = Depends(require_permission("providers")), ) -> dict[str, Any]: """Probe connectivity for a provider draft before it is saved.""" - api_key = (body.api_key or "").strip() + api_key, base_url = resolve_provider_credentials( + body.name, + api_key=body.api_key, + base_url=body.base_url, + ) if not api_key: return {"ok": False, "error": "api_key is required"} model_id = body.model_id.strip() @@ -312,7 +319,7 @@ async def admin_test_provider_draft( name=body.name.strip() or "draft", kind=body.kind, api_key=api_key, - base_url=(body.base_url or "").strip() or None, + base_url=base_url or None, model_id=model_id, extra_json=body.extra_json, embedding=body.embedding, @@ -337,12 +344,16 @@ async def admin_fetch_provider_models( "ok": False, "error": "fetch models is only supported for openai-compatible providers", } - api_key = (body.api_key or "").strip() + api_key, base_url = resolve_provider_credentials( + None, + api_key=body.api_key, + base_url=body.base_url, + ) if not api_key: return {"ok": False, "error": "api_key is required"} draft = SimpleNamespace(extra_json=body.extra_json) return await fetch_openai_compatible_models( - base_url=(body.base_url or "").strip() or None, + base_url=base_url or None, api_key=api_key, extra_headers=provider_headers(draft) or None, locale=resolve_request_locale(request), diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index 0d5c85c9..b38afc9c 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field from octop.api.deps import get_server, require_database, resolve_user_from_token, sign_token +from octop.infra.agents.providers.model_flags import resolve_provider_credentials from octop.infra.agents.providers.presets import load_provider_presets from octop.infra.agents.providers.probe import make_probe_provider_row, probe_provider_row from octop.infra.errors import ErrorCode, OctopError @@ -18,6 +19,7 @@ from octop.infra.setup.wizard_tokens import RateLimited from octop.infra.users.identity import Role from octop.infra.users.local_session import is_desktop_process +from octop.infra.utils.local_endpoint import is_local_base_url from octop.infra.utils.locale import normalize_locale, resolve_request_locale logger = logging.getLogger(__name__) @@ -175,8 +177,11 @@ async def _bootstrap_default_agent(server: Any, *, user_id: int, locale: str = " async def _apply_provider_draft(server: Any, draft: ProviderDraftBody) -> None: """Persist provider config from the wizard and reload harness providers.""" - api_key = (draft.api_key or "").strip() - base_url = (draft.base_url or "").strip() + api_key, base_url = resolve_provider_credentials( + draft.name, + api_key=draft.api_key, + base_url=draft.base_url, + ) if not api_key: raise OctopError(ErrorCode.INTERNAL_ERROR, "api_key is required", status=400) if not base_url: @@ -198,7 +203,7 @@ def _model_entry(m: ProviderModelDraft) -> dict[str, Any]: if not models and draft.models: models = [_model_entry(draft.models[0])] models[0]["enabled"] = True - if not models: + if not models and not is_local_base_url(base_url): raise OctopError(ErrorCode.INTERNAL_ERROR, "at least one model is required", status=400) server.services.provider_repo.create( name=draft.name, @@ -208,7 +213,8 @@ def _model_entry(m: ProviderModelDraft) -> dict[str, Any]: models_json=json.dumps(models), extra_json=json.dumps(draft.extras) if draft.extras else None, ) - server.services.settings_repo.set_active_model(draft.name, models[0]["id"]) + if models: + server.services.settings_repo.set_active_model(draft.name, models[0]["id"]) if server.app_runtime is not None: await server.app_runtime.agent_registry.on_provider_changed() @@ -407,13 +413,18 @@ async def test_provider_draft( """Probe LLM connectivity for an unsaved wizard provider draft.""" _enforce_wizard_token_phase(server) _authorize_setup_provider_test(authorization, server) - if not body.api_key.strip(): + api_key, base_url = resolve_provider_credentials( + body.name, + api_key=body.api_key, + base_url=body.base_url, + ) + if not api_key: return {"ok": False, "error": "api_key is required"} row = make_probe_provider_row( name=body.name, kind=body.type, - api_key=body.api_key or None, - base_url=body.base_url, + api_key=api_key, + base_url=base_url or None, model_id=body.model_id, ) return await probe_provider_row(row, locale=resolve_request_locale(request)) diff --git a/src/octop/infra/agents/providers/model_flags.py b/src/octop/infra/agents/providers/model_flags.py index 3718b490..3fa6a6b7 100644 --- a/src/octop/infra/agents/providers/model_flags.py +++ b/src/octop/infra/agents/providers/model_flags.py @@ -73,6 +73,29 @@ def local_ollama_endpoint( return api_key, base_url +def resolve_provider_credentials( + provider_name: str | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, +) -> tuple[str, str]: + """Fill Ollama / loopback placeholders so local runtimes need no vendor key. + + Returns ``(api_key, base_url)``. Either side may still be empty when the + row is a cloud provider that the caller has not configured yet. + """ + from octop.infra.utils.local_endpoint import placeholder_api_key + + filled_key, filled_url = local_ollama_endpoint( + provider_name, + provider_api_key=api_key, + provider_base_url=base_url, + ) + url = (filled_url or "").strip() + key = placeholder_api_key(url, filled_key) + return key, url + + def is_local_runtime_provider( provider_name: str | None = None, *, diff --git a/src/octop/infra/agents/providers/presets.py b/src/octop/infra/agents/providers/presets.py index 039bf3a6..82555727 100644 --- a/src/octop/infra/agents/providers/presets.py +++ b/src/octop/infra/agents/providers/presets.py @@ -211,6 +211,17 @@ def _reasoning_profile(provider_id: str, model_id: str) -> dict[str, Any] | None return None +_LOCAL_PRESET_RANK = {"ollama": 0, "onnx": 1} + + +def _prefer_local_presets(presets: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Put Ollama / ONNX first so CLI, wizard, and APIs surface the local path.""" + local = [p for p in presets if p.get("id") in _LOCAL_PRESET_RANK] + rest = [p for p in presets if p.get("id") not in _LOCAL_PRESET_RANK] + local.sort(key=lambda p: _LOCAL_PRESET_RANK.get(str(p.get("id") or ""), 99)) + return local + rest + + def load_provider_presets() -> list[dict[str, Any]]: """Serialize harness-agent provider templates for API / CLI.""" from importlib import resources @@ -269,6 +280,7 @@ def load_provider_presets() -> list[dict[str, Any]]: len(out), ) out.insert(insert_at, onnx_preset) + out = _prefer_local_presets(out) for preset in out: provider_id = str(preset.get("id") or "") for model in preset.get("models") or []: diff --git a/src/octop/infra/utils/local_endpoint.py b/src/octop/infra/utils/local_endpoint.py new file mode 100644 index 00000000..bd2a4d25 --- /dev/null +++ b/src/octop/infra/utils/local_endpoint.py @@ -0,0 +1,39 @@ +"""Detect loopback / LAN-local OpenAI-compatible endpoints. + +Used when filling optional API-key placeholders for Ollama, LM Studio, +llama.cpp, vLLM, and similar runtimes that already sit on the operator's machine. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +# Many local OpenAI-compatible servers accept any non-empty Bearer token. +LOCAL_PLACEHOLDER_API_KEY = "local" + +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"}) +_LOCAL_SERVICE_HOSTS = frozenset({"ollama", "lmstudio", "vllm"}) + + +def is_local_base_url(url: str | None) -> bool: + """True when *url* points at this machine or a well-known local runtime host.""" + raw = (url or "").strip() + if not raw: + return False + parsed = urlparse(raw if "://" in raw else f"http://{raw}") + host = (parsed.hostname or "").lower() + if not host: + return False + if host in _LOOPBACK_HOSTS or host in _LOCAL_SERVICE_HOSTS: + return True + return host.endswith(".local") + + +def placeholder_api_key(url: str | None, existing: str | None = None) -> str: + """Return a usable API key, filling a local placeholder when the URL is local.""" + key = (existing or "").strip() + if key: + return key + if is_local_base_url(url): + return LOCAL_PLACEHOLDER_API_KEY + return "" diff --git a/tests/unit/knowledge/test_gate.py b/tests/unit/knowledge/test_gate.py index 31285b98..0c552d93 100644 --- a/tests/unit/knowledge/test_gate.py +++ b/tests/unit/knowledge/test_gate.py @@ -34,6 +34,7 @@ def test_capability_defaults_enabled_on_desktop(monkeypatch: pytest.MonkeyPatch) capability = gate.get_capability(lambda _key: None) assert capability["feature_enabled"] is True + assert capability["backend"] == "onnx" assert capability["usable"] is False diff --git a/tests/unit/test_provider_preset_expansion.py b/tests/unit/test_provider_preset_expansion.py index 7ad08e16..fd5fe351 100644 --- a/tests/unit/test_provider_preset_expansion.py +++ b/tests/unit/test_provider_preset_expansion.py @@ -8,6 +8,8 @@ def test_load_provider_presets_integration() -> None: presets = load_provider_presets() ids = {p["id"] for p in presets} + assert presets[0]["id"] == "ollama" + assert any(p["id"] == "onnx" for p in presets) assert "moonshot" not in ids assert "kimi-cn" in ids assert "minimax-intl" in ids diff --git a/tests/unit/utils/test_local_endpoint.py b/tests/unit/utils/test_local_endpoint.py new file mode 100644 index 00000000..5f019e02 --- /dev/null +++ b/tests/unit/utils/test_local_endpoint.py @@ -0,0 +1,40 @@ +"""Local OpenAI-compatible endpoint helpers.""" + +from __future__ import annotations + +from octop.infra.agents.providers.model_flags import resolve_provider_credentials +from octop.infra.utils.local_endpoint import ( + LOCAL_PLACEHOLDER_API_KEY, + is_local_base_url, + placeholder_api_key, +) + + +def test_loopback_and_local_service_hosts_are_local() -> None: + assert is_local_base_url("http://127.0.0.1:11434/v1") + assert is_local_base_url("http://localhost:1234/v1") + assert is_local_base_url("http://ollama:11434/v1") + assert not is_local_base_url("https://api.openai.com/v1") + assert not is_local_base_url("") + + +def test_placeholder_api_key_fills_only_local_urls() -> None: + assert placeholder_api_key("http://127.0.0.1:8080/v1", "") == LOCAL_PLACEHOLDER_API_KEY + assert placeholder_api_key("https://api.openai.com/v1", "") == "" + assert placeholder_api_key("http://127.0.0.1:8080/v1", "sk-keep") == "sk-keep" + + +def test_resolve_provider_credentials_fills_ollama_defaults() -> None: + key, url = resolve_provider_credentials("Ollama (Local)", api_key="", base_url="") + assert key == "ollama" + assert url == "http://127.0.0.1:11434/v1" + + +def test_resolve_provider_credentials_fills_lm_studio_style_url() -> None: + key, url = resolve_provider_credentials( + "LM Studio", + api_key="", + base_url="http://127.0.0.1:1234/v1", + ) + assert key == LOCAL_PLACEHOLDER_API_KEY + assert url == "http://127.0.0.1:1234/v1" From 5118bc436cdd0490c5985a2ab1071842631372c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 10:59:12 +0000 Subject: [PATCH 2/2] fix: keep ONNX embedding preset out of wizard featured cards Wizard featured slots previously pulled ONNX in when fewer than six cloud brands were listed. Embedding stays under more; local chat remains first. Guard the connectivity test when no model is selected yet. Co-authored-by: XYAI Labs --- .../pages/Settings/Models/presetUtils.test.ts | 5 +++ .../src/pages/Settings/Models/presetUtils.ts | 33 +++++++++++++------ dashboard/src/pages/Setup/steps/ModelStep.tsx | 16 ++++++--- tests/unit/test_provider_preset_expansion.py | 2 +- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/dashboard/src/pages/Settings/Models/presetUtils.test.ts b/dashboard/src/pages/Settings/Models/presetUtils.test.ts index fa3c25b5..5ce144c6 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.test.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.test.ts @@ -77,6 +77,11 @@ describe("local-first wizard and settings helpers", () => { onnx, ]); expect(featured[0]).toEqual({ kind: "single", preset: ollama }); + expect( + featured.some( + (item) => item.kind === "single" && item.preset.id === "onnx", + ), + ).toBe(false); expect( more.some((item) => item.kind === "single" && item.preset.id === "onnx"), ).toBe(true); diff --git a/dashboard/src/pages/Settings/Models/presetUtils.ts b/dashboard/src/pages/Settings/Models/presetUtils.ts index b2a89193..08b49c77 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.ts @@ -176,26 +176,39 @@ export function buildWizardPresetDisplay(presets: ProviderPreset[]): { more: WizardPresetDisplayItem[]; } { const localChat = presets.filter((p) => isLocalChatPreset(p)); - const rest = presets.filter((p) => !isLocalChatPreset(p)); + const localEmbed = presets.filter( + (p) => isLocalPreset(p) && !isLocalChatPreset(p), + ); + const rest = presets.filter((p) => !isLocalPreset(p)); const { grouped, ungrouped } = groupPresets(rest); const restItems: WizardPresetDisplayItem[] = [ - ...grouped.map((group): WizardPresetDisplayItem => ({ - kind: "group", - group, - })), - ...ungrouped.map((preset): WizardPresetDisplayItem => ({ - kind: "single", - preset, - })), + ...grouped.map( + (group): WizardPresetDisplayItem => ({ + kind: "group", + group, + }), + ), + ...ungrouped.map( + (preset): WizardPresetDisplayItem => ({ + kind: "single", + preset, + }), + ), ]; const localItems: WizardPresetDisplayItem[] = localChat.map((preset) => ({ kind: "single", preset, })); + const localEmbedItems: WizardPresetDisplayItem[] = localEmbed.map( + (preset) => ({ + kind: "single" as const, + preset, + }), + ); const remainingSlots = Math.max(WIZARD_FEATURED_COUNT - localItems.length, 0); return { featured: [...localItems, ...restItems.slice(0, remainingSlots)], - more: restItems.slice(remainingSlots), + more: [...restItems.slice(remainingSlots), ...localEmbedItems], }; } diff --git a/dashboard/src/pages/Setup/steps/ModelStep.tsx b/dashboard/src/pages/Setup/steps/ModelStep.tsx index 8e63e9fd..04e780e4 100644 --- a/dashboard/src/pages/Setup/steps/ModelStep.tsx +++ b/dashboard/src/pages/Setup/steps/ModelStep.tsx @@ -231,7 +231,8 @@ export default function ModelStep({ const isOllama = preset?.id === "ollama"; const customBaseUrl = Form.useWatch("base_url", customForm) as - string | undefined; + | string + | undefined; const customIsLocal = isLocalBaseUrl(customBaseUrl); const canContinueWithoutTest = (mode === "preset" && isOllama) || (mode === "custom" && customIsLocal); @@ -494,6 +495,11 @@ export default function ModelStep({ } const draft = result.draft; + const probeModel = draft.models[0]; + if (!probeModel) { + message.warning(t("models.addModelFirst")); + return; + } setTesting(true); resetTest(); try { @@ -503,14 +509,14 @@ export default function ModelStep({ type: draft.type, api_key: draft.api_key, base_url: draft.base_url, - model_id: draft.models[0].id, + model_id: probeModel.id, }, probeToken, ); if (result.ok) { message.success( t("models.testSuccess", { - name: draft.models[0].name, + name: probeModel.name, time: result.latency_ms ?? 0, }), ); @@ -1102,8 +1108,8 @@ export default function ModelStep({ k.value === "openai" ? t("models.kindOpenaiCompat") : k.value === "anthropic" - ? "Anthropic" - : "AWS Bedrock", + ? "Anthropic" + : "AWS Bedrock", }))} /> diff --git a/tests/unit/test_provider_preset_expansion.py b/tests/unit/test_provider_preset_expansion.py index fd5fe351..b0b8ef8b 100644 --- a/tests/unit/test_provider_preset_expansion.py +++ b/tests/unit/test_provider_preset_expansion.py @@ -9,7 +9,7 @@ def test_load_provider_presets_integration() -> None: presets = load_provider_presets() ids = {p["id"] for p in presets} assert presets[0]["id"] == "ollama" - assert any(p["id"] == "onnx" for p in presets) + assert presets[1]["id"] == "onnx" assert "moonshot" not in ids assert "kimi-cn" in ids assert "minimax-intl" in ids