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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### 新增

- 模型页本机大模型:Ollama 已安装但未运行时显示「启动 Ollama」(Windows 走已知安装路径的 `Ollama.exe` / `ollama app` / `ollama serve`);缺少可自动处理的依赖时提供「一键下载安装」,否则说明需手动安装;「搜索本机模型」后台扫描 `.gguf` / `.ggml`(以及明确的本地 LLM safetensors 目录),结果进入本机列表并可注册到 Ollama 供对话切换。
- 组织页双向互生长:FreeOS 资产经 `/api/freeos/ingest`(共享写入令牌,无需边车登录墙)落到 openXYOS 员工/人才/插件/技能表;控制面导出再装配回 FreeOS。循环在边车健康时不再把「已推送到控制面」写成 false。
- 组织页可从本地语料或 ima / 云知识库指针一键生产同事;本地模型一键下载后会注册到 Ollama 供应商。

Expand Down
90 changes: 82 additions & 8 deletions dashboard/src/api/modules/localModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,27 @@ export interface LocalHardware {
ram_gb: number;
gpu: string;
ollama_binary: boolean;
ollama_installed?: boolean;
ollama_reachable: boolean;
ollama_path?: string;
}

export interface LocalDep {
id: string;
kind: string;
automatable: boolean;
method?: string | null;
docs_url?: string;
next_step?: string;
}

export interface LocalInstalledModel {
name: string;
path: string;
size: number;
source: string;
registerable?: boolean;
registered?: boolean;
}

export interface LocalRecommendedModel {
Expand All @@ -25,19 +38,80 @@ export interface LocalRecommendedModel {

export interface LocalProbe {
hardware: LocalHardware;
deps?: LocalDep[];
installed: LocalInstalledModel[];
recommended: LocalRecommendedModel[];
}

export interface LocalRuntimeResult {
ok: boolean;
installed: boolean;
running?: boolean;
action?: string;
automatable?: boolean;
method?: string | null;
command?: string;
docs_url?: string;
next_step?: string;
error?: string | null;
name?: string;
size?: number;
source?: string;
provider_name?: string;
}

export interface LocalScanJob {
job_id: string | null;
status: string;
roots?: string[];
full_disk?: boolean;
dirs_scanned?: number;
files_found?: number;
current?: string;
found?: LocalInstalledModel[];
error?: string | null;
}

export const localModelsApi = {
probe: () => request<LocalProbe>("/local-models/probe"),
install: (name: string) =>
request<{ ok: boolean; name: string; size: number; source: string }>(
"/local-models/install",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
},
),
request<LocalRuntimeResult>("/local-models/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
startOllama: () =>
request<LocalRuntimeResult>("/local-models/start-ollama", {
method: "POST",
}),
ensureDeps: (install: boolean) =>
request<LocalRuntimeResult>("/local-models/ensure-deps", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ install }),
}),
startScan: (body: { root?: string; full_disk?: boolean }) =>
request<LocalScanJob>("/local-models/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
getScan: (jobId: string) =>
request<LocalScanJob>(`/local-models/scan/${encodeURIComponent(jobId)}`),
getLatestScan: () => request<LocalScanJob>("/local-models/scan"),
cancelScan: (jobId: string) =>
request<LocalScanJob>(`/local-models/scan/${encodeURIComponent(jobId)}`, {
method: "DELETE",
}),
register: (body: {
path: string;
name?: string;
source: string;
size?: number;
}) =>
request<LocalRuntimeResult>("/local-models/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
};
30 changes: 30 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2988,6 +2988,36 @@
"localInstallDone": "Installed {{name}}",
"localInstallFailed": "Install failed",
"localProbeFailed": "Could not scan local models",
"localStartOllama": "Start Ollama",
"localOllamaStarted": "Ollama is running",
"localOllamaStartFailed": "Could not start Ollama",
"localOllamaInstalledStopped": "installed, not running",
"localOllamaInstalledNotRunning": "Ollama is installed on this PC, but the local API is not reachable.",
"localOllamaMissing": "Ollama is not installed",
"localOllamaMissingAuto": "FreeOS can try a one-click install with the package manager on this PC.",
"localOllamaMissingManual": "FreeOS cannot install Ollama automatically here. Download the official installer, then return to this page.",
"localOneClickInstall": "Download and install",
"localDepsNeededTitle": "Local runtime is missing",
"localDepsNeededBody": "This model needs Ollama. Start it if it is already installed, or run the one-click installer when FreeOS can automate that step.",
"localDepsInstalled": "Local runtime is ready",
"localDepsFailed": "Could not prepare the local runtime",
"localSearchModels": "Search this PC",
"localScanPickFolder": "Choose folder",
"localScanRootPlaceholder": "Optional folder (default: user profile + common model dirs)",
"localScanPickHint": "Type a folder path, or use the desktop app to pick one.",
"localScanFullDisk": "Entire disk",
"localScanFullDiskTitle": "Scan the entire disk?",
"localScanFullDiskWarn": "A full-disk scan can take a long time and will skip system folders. Prefer a folder if you know where the weights are.",
"localScanStart": "Start scan",
"localScanProgress": "Scanned {{dirs}} folders, found {{files}} · {{current}}",
"localScanDone": "Found {{count}} local model file(s)",
"localScanFailed": "Local model search failed",
"localScanCancelFailed": "Could not cancel the scan",
"localRegister": "Register",
"localRegisterDone": "Registered {{name}} for chat",
"localRegisterFailed": "Could not register this file",
"localRegisterManual": "Needs a manual GGUF conversion — FreeOS will not invent one",
"localOllamaManaged": "Managed by Ollama",
"localDownloadConfirmTitle": "Confirm model download",
"localDownloadConfirmOnnx": "Download {{name}} (about {{size}}). This may take a few minutes.",
"localDownloadConfirmOllama": "Pull {{name}} via Ollama. Size depends on the model and may take a while.",
Expand Down
30 changes: 30 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2986,6 +2986,36 @@
"localInstallDone": "已安装 {{name}}",
"localInstallFailed": "安装失败",
"localProbeFailed": "无法扫描本地模型",
"localStartOllama": "启动 Ollama",
"localOllamaStarted": "Ollama 已启动",
"localOllamaStartFailed": "无法启动 Ollama",
"localOllamaInstalledStopped": "已安装未运行",
"localOllamaInstalledNotRunning": "已检测到本机安装了 Ollama,但本地接口尚未就绪。",
"localOllamaMissing": "未安装 Ollama",
"localOllamaMissingAuto": "这台电脑可以用包管理器由 FreeOS 尝试一键安装。",
"localOllamaMissingManual": "当前环境无法自动安装 Ollama。请从官网下载安装包,完成后再回到此页。",
"localOneClickInstall": "一键下载安装",
"localDepsNeededTitle": "缺少本地运行依赖",
"localDepsNeededBody": "该推荐模型需要 Ollama。若已安装请先启动;若尚未安装且 FreeOS 能自动处理,可一键下载安装。",
"localDepsInstalled": "本地运行环境已就绪",
"localDepsFailed": "无法准备本地运行环境",
"localSearchModels": "搜索本机模型",
"localScanPickFolder": "选择文件夹",
"localScanRootPlaceholder": "可选扫描目录(默认:用户目录 + 常见模型目录)",
"localScanPickHint": "可手动填写路径;桌面端可用系统文件夹选择器。",
"localScanFullDisk": "全盘",
"localScanFullDiskTitle": "要搜索全盘吗?",
"localScanFullDiskWarn": "全盘扫描可能很慢,并会跳过系统目录。如果知道模型所在位置,建议先选文件夹。",
"localScanStart": "开始搜索",
"localScanProgress": "已扫描 {{dirs}} 个目录,发现 {{files}} 个 · {{current}}",
"localScanDone": "找到 {{count}} 个本机模型文件",
"localScanFailed": "搜索本机模型失败",
"localScanCancelFailed": "无法取消搜索",
"localRegister": "注册",
"localRegisterDone": "已注册 {{name}},可在对话中切换",
"localRegisterFailed": "无法注册该文件",
"localRegisterManual": "需要手动转为 GGUF,FreeOS 不会代为转换",
"localOllamaManaged": "由 Ollama 管理",
"localDownloadConfirmTitle": "确认下载模型",
"localDownloadConfirmOnnx": "即将下载 {{name}}(约 {{size}})。下载可能需要几分钟,请保持网络畅通。",
"localDownloadConfirmOllama": "即将通过 Ollama 拉取 {{name}}。下载体积取决于模型,可能需要较长时间。",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

const probe = vi.fn();
const startOllama = vi.fn();
const ensureDeps = vi.fn();
const startScan = vi.fn();
const register = vi.fn();

vi.mock("../../../../api/modules/localModels", () => ({
localModelsApi: {
probe: (...args: unknown[]) => probe(...args),
startOllama: (...args: unknown[]) => startOllama(...args),
ensureDeps: (...args: unknown[]) => ensureDeps(...args),
install: vi.fn(),
startScan: (...args: unknown[]) => startScan(...args),
getScan: vi.fn(),
getLatestScan: vi.fn(),
cancelScan: vi.fn(),
register: (...args: unknown[]) => register(...args),
},
}));

vi.mock("../../../../utils/desktopFolder", () => ({
canPickDesktopFolder: () => false,
pickDesktopFolder: async () => null,
}));

vi.mock("../../../../utils/antdMessage", () => ({
message: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));

import { LocalHardwarePanel } from "./LocalHardwarePanel";

const installedStopped = {
hardware: {
os: "Windows",
arch: "AMD64",
cpu_count: 8,
ram_gb: 16,
gpu: "",
ollama_binary: true,
ollama_installed: true,
ollama_reachable: false,
ollama_path: "C:\\\\Ollama\\\\ollama.exe",
},
deps: [
{
id: "ollama_daemon",
kind: "runtime",
automatable: true,
method: "start",
},
],
installed: [
{
name: "tiny",
path: "D:\\\\models\\\\tiny.gguf",
size: 2048,
source: "gguf",
registerable: true,
},
],
recommended: [{ id: "llama3.2:3b", reason: "3B", install: "ollama" }],
};

beforeEach(() => {
vi.clearAllMocks();
probe.mockResolvedValue(installedStopped);
startOllama.mockResolvedValue({ ok: true, installed: true, running: true });
startScan.mockResolvedValue({
job_id: "job-1",
status: "completed",
found: installedStopped.installed,
});
});

describe("<LocalHardwarePanel />", () => {
it("shows Start Ollama when the binary exists but the API is down", async () => {
render(<LocalHardwarePanel />);
await waitFor(() => expect(probe).toHaveBeenCalled());
expect(screen.getByText("models.localStartOllama")).toBeInTheDocument();
await userEvent.click(screen.getByText("models.localStartOllama"));
await waitFor(() => expect(startOllama).toHaveBeenCalled());
});

it("lists discovered GGUF files with a register action", async () => {
render(<LocalHardwarePanel />);
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(<LocalHardwarePanel />);
await waitFor(() => expect(probe).toHaveBeenCalled());
await userEvent.click(screen.getByText("models.localSearchModels"));
await waitFor(() => expect(startScan).toHaveBeenCalled());
});
});
Loading
Loading