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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@

## [Unreleased]

## [0.0.7] - 2026-09-29

### 新增

- 面向未安装 Ollama、也没有 GGUF 模型的新用户,模型页提供经过固定版本与 SHA-256 校验的 GGUF 模型目录、一键下载、实时进度、自动启动内置 llama.cpp、首次测速并设为默认模型。
- Windows amd64 桌面包内置固定版本的 llama.cpp sidecar;已安装 Ollama 但服务未启动时,首次配置页允许用户自行选择启动 Ollama。

### 变更

- 本机模型下载任务和测速结果跨 FreeOS 重启保留;下载中断、取消或临时失败后可从已有部分文件继续,开始前会检查剩余磁盘空间。
- 用户明确启动过的 llama.cpp 模型会在下次启动 FreeOS 时自动恢复;用户主动停止后不会再次自动拉起。

### 修复

- 同一目录模型下载自动去重,服务器不支持 HTTP Range 时安全回退为完整重下,完整 `.part` 文件会先校验再直接完成,避免重复流量与并发覆盖。

## [0.0.6] - 2026-09-22

### 安全
Expand Down
65 changes: 61 additions & 4 deletions dashboard/src/api/modules/localModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export interface LocalHardware {
ollama_installed?: boolean;
ollama_reachable: boolean;
ollama_path?: string;
llamacpp_binary?: boolean;
llamacpp_reachable?: boolean;
llamacpp_path?: string;
}

export interface LocalDep {
Expand All @@ -28,16 +31,37 @@ export interface LocalInstalledModel {
source: string;
registerable?: boolean;
registered?: boolean;
runtime?: string;
managed_by_freeos?: boolean;
base_url?: string;
model_path?: string;
alias?: string;
provider_name?: string;
is_default?: boolean;
}

export interface LocalRecommendedModel {
id: string;
name?: string;
display_name?: string;
size?: number;
reason: string;
install: string;
}

export interface LocalDownloadJob {
job_id: string;
catalog_id: string;
name?: string;
status: string;
downloaded_bytes: number;
total_bytes: number;
percent: number;
path: string;
error?: string | null;
resumable?: boolean;
}

export interface LocalProbe {
hardware: LocalHardware;
deps?: LocalDep[];
Expand Down Expand Up @@ -110,10 +134,43 @@ export const localModelsApi = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
startDownload: (catalogId: string) =>
request<LocalDownloadJob>("/local-models/downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ catalog_id: catalogId }),
}),
listDownloads: () => request<LocalDownloadJob[]>("/local-models/downloads"),
getDownload: (jobId: string) =>
request<LocalDownloadJob>(
`/local-models/downloads/${encodeURIComponent(jobId)}`,
),
cancelDownload: (jobId: string) =>
request<LocalDownloadJob>(
`/local-models/downloads/${encodeURIComponent(jobId)}`,
{ method: "DELETE" },
),
startOllama: () =>
request<LocalRuntimeResult>("/local-models/start-ollama", {
method: "POST",
}),
llamaCppStatus: () =>
request<LocalRuntimeResult>("/local-models/llamacpp/status"),
startLlamaCpp: (body: {
model_path: string;
alias?: string;
context_size?: number;
gpu_layers?: number;
}) =>
request<LocalRuntimeResult>("/local-models/llamacpp/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
stopLlamaCpp: () =>
request<LocalRuntimeResult>("/local-models/llamacpp", {
method: "DELETE",
}),
ensureDeps: (install: boolean) =>
request<LocalRuntimeResult>("/local-models/ensure-deps", {
method: "POST",
Expand Down Expand Up @@ -144,18 +201,18 @@ export const localModelsApi = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
speedTest: (name: string, init?: RequestInit) =>
speedTest: (name: string, providerName?: string, init?: RequestInit) =>
request<LocalSpeedTestResult>("/local-models/speed-test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, provider_name: providerName }),
...init,
}),
setDefault: (name: string) =>
setDefault: (name: string, providerName?: string) =>
request<LocalDefaultResult>("/local-models/default", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, provider_name: providerName }),
}),
clearDefault: (name?: string) =>
request<LocalDefaultResult>(
Expand Down
20 changes: 18 additions & 2 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3205,7 +3205,7 @@
"localNoModels": "No downloaded models yet",
"ollamaUnavailable": "Ollama is not installed or not running. Please install Ollama and start the daemon.",
"localDownloadSuccess": "Model downloaded successfully",
"localDownloadFailed": "Failed to download model",
"localDownloadFailed": "Model download failed",
"localDeleteModel": "Delete Model",
"localDeleteConfirm": "Delete model \"{{name}}\"? The model will be removed.",
"localModelDeleted": "Model \"{{name}}\" deleted",
Expand Down Expand Up @@ -3483,7 +3483,23 @@
"onnxQuickDownload": "Quick download from catalog…",
"useInChat": "Switch in chat",
"localInstallRegistered": "Installed {{name}} and registered it. Open Conversations to switch.",
"localInstallNeedProvider": "Downloaded. Enable the Ollama provider on this page, then switch in chat."
"localInstallNeedProvider": "Downloaded. Enable the Ollama provider on this page, then switch in chat.",
"localBuiltinRuntime": "FreeOS built-in runtime",
"localBuiltinMissing": "The built-in local-model runtime is missing",
"localBuiltinMissingHelp": "Repair or update FreeOS to restore the bundled llama.cpp runtime.",
"localRunBuiltin": "Run with FreeOS",
"localBuiltinStarted": "Started {{name}} with the FreeOS local runtime",
"localBuiltinStartFailed": "Failed to start the FreeOS local runtime",
"localRecommendedHint": "No Ollama required. FreeOS downloads the model, starts its bundled runtime, benchmarks it, and selects it for local chat after a successful test.",
"localInstallAndRecommend": "Download and recommend",
"localDownloadProgress": "Downloaded {{current}} / {{total}}",
"localDownloadInterrupted": "The previous model download did not finish. You can continue from the saved progress.",
"localDownloadResume": "Resume download",
"localAutoSetupDone": "{{name}} was downloaded, benchmarked, and selected for local chat",
"localCatalogReason": {
"recommended_for_hardware": "Recommended for this computer",
"lighter_or_stronger_alternative": "Alternative model"
}
},
"advancedSettings": {
"description": "Manage runtime configuration and environment variables.",
Expand Down
18 changes: 17 additions & 1 deletion dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -3480,7 +3480,23 @@
"onnxQuickDownload": "从目录快速下载…",
"useInChat": "去对话切换",
"localInstallRegistered": "已安装 {{name}} 并登记到模型列表。打开「对话」即可切换。",
"localInstallNeedProvider": "模型已下载。请先在本页启用 Ollama 提供商,再到对话里切换。"
"localInstallNeedProvider": "模型已下载。请先在本页启用 Ollama 提供商,再到对话里切换。",
"localBuiltinRuntime": "FreeOS 内置运行时",
"localBuiltinMissing": "缺少内置本地模型运行时",
"localBuiltinMissingHelp": "请修复或更新 FreeOS,以恢复随软件提供的 llama.cpp 运行时。",
"localRunBuiltin": "使用 FreeOS 运行",
"localBuiltinStarted": "已使用 FreeOS 本地运行时启动 {{name}}",
"localBuiltinStartFailed": "FreeOS 本地运行时启动失败",
"localRecommendedHint": "无需安装 Ollama。下载完成后,FreeOS 会自动启动内置运行时、测速,并将通过测试的模型设为本地对话推荐。",
"localInstallAndRecommend": "一键下载并推荐",
"localDownloadProgress": "已下载 {{current}} / {{total}}",
"localDownloadInterrupted": "上次模型下载尚未完成,可以从已有进度继续。",
"localDownloadResume": "继续下载",
"localAutoSetupDone": "{{name}} 已下载、测速并设为本地推荐模型",
"localCatalogReason": {
"recommended_for_hardware": "根据本机内存推荐",
"lighter_or_stronger_alternative": "备选模型"
}
},
"advancedSettings": {
"description": "管理运行配置和环境变量等高级选项。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@ import { MemoryRouter } from "react-router-dom";

const probe = vi.fn();
const startOllama = vi.fn();
const startLlamaCpp = vi.fn();
const ensureDeps = vi.fn();
const startScan = vi.fn();
const register = vi.fn();
const speedTest = vi.fn();
const setDefault = vi.fn();
const clearDefault = vi.fn();
const startDownload = vi.fn();
const listDownloads = vi.fn();

vi.mock("../../../../api/modules/localModels", () => ({
localModelsApi: {
probe: (...args: unknown[]) => probe(...args),
startOllama: (...args: unknown[]) => startOllama(...args),
startLlamaCpp: (...args: unknown[]) => startLlamaCpp(...args),
ensureDeps: (...args: unknown[]) => ensureDeps(...args),
install: vi.fn(),
startDownload: (...args: unknown[]) => startDownload(...args),
listDownloads: (...args: unknown[]) => listDownloads(...args),
getDownload: vi.fn(),
cancelDownload: vi.fn(),
startScan: (...args: unknown[]) => startScan(...args),
getScan: vi.fn(),
getLatestScan: vi.fn(),
Expand Down Expand Up @@ -51,6 +59,9 @@ const installedStopped = {
ollama_installed: true,
ollama_reachable: false,
ollama_path: "C:\\\\Ollama\\\\ollama.exe",
llamacpp_binary: true,
llamacpp_reachable: false,
llamacpp_path: "C:\\\\FreeOS\\\\llama.cpp\\\\llama-server.exe",
},
deps: [
{
Expand All @@ -76,11 +87,18 @@ beforeEach(() => {
vi.clearAllMocks();
probe.mockResolvedValue(installedStopped);
startOllama.mockResolvedValue({ ok: true, installed: true, running: true });
startLlamaCpp.mockResolvedValue({
ok: true,
installed: true,
running: true,
registered: true,
});
startScan.mockResolvedValue({
job_id: "job-1",
status: "completed",
found: installedStopped.installed,
});
listDownloads.mockResolvedValue([]);
});

function renderPanel(props: { onSaved?: () => void | Promise<void> } = {}) {
Expand All @@ -105,6 +123,20 @@ describe("<LocalHardwarePanel />", () => {
await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument());
expect(screen.getByText("D:\\\\models\\\\tiny.gguf")).toBeInTheDocument();
expect(screen.getByText("models.localRegister")).toBeInTheDocument();
expect(screen.getByText("models.localRunBuiltin")).toBeInTheDocument();
});

it("starts a discovered GGUF with the built-in runtime", async () => {
renderPanel();
await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument());
await userEvent.click(screen.getByText("models.localRunBuiltin"));
await waitFor(() =>
expect(startLlamaCpp).toHaveBeenCalledWith({
model_path: "D:\\\\models\\\\tiny.gguf",
alias: "tiny",
gpu_layers: -1,
}),
);
});

it("starts a local weight search", async () => {
Expand Down Expand Up @@ -142,10 +174,16 @@ describe("<LocalHardwarePanel />", () => {
expect(screen.getByText("models.localSetDefault")).toBeInTheDocument();
await userEvent.click(screen.getByText("models.localSpeedTest"));
await waitFor(() =>
expect(speedTest).toHaveBeenCalledWith("tiny", expect.anything()),
expect(speedTest).toHaveBeenCalledWith(
"tiny",
undefined,
expect.anything(),
),
);
await userEvent.click(screen.getByText("models.localSetDefault"));
await waitFor(() => expect(setDefault).toHaveBeenCalledWith("tiny"));
await waitFor(() =>
expect(setDefault).toHaveBeenCalledWith("tiny", undefined),
);
});

it("shows a default badge and can clear it", async () => {
Expand Down Expand Up @@ -184,4 +222,88 @@ describe("<LocalHardwarePanel />", () => {
expect(onSaved).toHaveBeenCalled();
window.removeEventListener("octop:models-changed", heard);
});

it("offers to resume an interrupted catalog download", async () => {
listDownloads.mockResolvedValue([
{
job_id: "download-old",
catalog_id: "starter",
name: "starter-model",
status: "interrupted",
downloaded_bytes: 512,
total_bytes: 1024,
percent: 50,
path: "D:\\models\\starter.gguf",
resumable: true,
},
]);
startDownload.mockResolvedValue({
job_id: "download-new",
catalog_id: "starter",
name: "starter-model",
status: "running",
downloaded_bytes: 512,
total_bytes: 1024,
percent: 50,
path: "D:\\models\\starter.gguf",
resumable: true,
});

renderPanel();
await screen.findByText("models.localDownloadResume");
await userEvent.click(screen.getByText("models.localDownloadResume"));

await waitFor(() => expect(startDownload).toHaveBeenCalledWith("starter"));
});

it("downloads, benchmarks, and selects a catalog model", async () => {
probe.mockResolvedValue({
...installedStopped,
recommended: [
{
id: "starter",
name: "starter-model",
display_name: "Starter model",
reason: "recommended_for_hardware",
install: "freeos",
size: 1024,
},
],
});
startDownload.mockResolvedValue({
job_id: "download-1",
catalog_id: "starter",
name: "starter-model",
status: "completed",
downloaded_bytes: 1024,
total_bytes: 1024,
percent: 100,
path: "D:\\models\\starter.gguf",
});
startLlamaCpp.mockResolvedValue({
ok: true,
installed: true,
running: true,
name: "starter-model",
provider_name: "FreeOS llama.cpp",
});
speedTest.mockResolvedValue({ ok: true, latency_ms: 50 });
setDefault.mockResolvedValue({ ok: true });

renderPanel();
await screen.findByText("Starter model");
await userEvent.click(screen.getByText("models.localInstallAndRecommend"));

await waitFor(() => expect(startDownload).toHaveBeenCalledWith("starter"));
expect(startLlamaCpp).toHaveBeenCalledWith({
model_path: "D:\\models\\starter.gguf",
alias: "starter-model",
gpu_layers: -1,
});
expect(speedTest).toHaveBeenCalledWith("starter-model", "FreeOS llama.cpp");
expect(setDefault).toHaveBeenCalledWith(
"starter-model",
"FreeOS llama.cpp",
);
});
});
Loading
Loading