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 @@ -38,6 +38,7 @@

### 新增

- 模型页本机模型支持「测速」和「设为默认」:测速对 Ollama `/api/generate`(或已注册供应商的 chat completions)发送固定短提示,展示总延迟、首字时间和 tok/s,可取消,超时约 45 秒;设为默认写入用户 `preferred_model` 与全局 `active_model`,新对话和模型选择器会优先使用它,当前默认显示徽章,可一键取消。
- 开发空间对话右侧栏:可折叠,空状态「开始一个面板」,从文件 / 审查 / 后台任务 / 浏览器 / 终端起步。输入框「+」打开同一组面板,「使用权限」提供默认权限 / 自动审批 / 完全访问并随对话发送。
- 开发空间对话区历史横杠:沿滚动条一侧显示回合刻度,悬停看摘要,点击跳到对应消息;打开右侧栏后仍可见。
- 模型页本机大模型:Ollama 已安装但未运行时显示「启动 Ollama」(Windows 走已知安装路径的 `Ollama.exe` / `ollama app` / `ollama serve`);缺少可自动处理的依赖时提供「一键下载安装」,否则说明需手动安装;「搜索本机模型」后台扫描 `.gguf` / `.ggml`(以及明确的本地 LLM safetensors 目录),结果进入本机列表并可注册到 Ollama 供对话切换。
Expand Down
49 changes: 49 additions & 0 deletions dashboard/src/api/modules/localModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface LocalInstalledModel {
source: string;
registerable?: boolean;
registered?: boolean;
provider_name?: string;
is_default?: boolean;
}

export interface LocalRecommendedModel {
Expand All @@ -41,6 +43,33 @@ export interface LocalProbe {
deps?: LocalDep[];
installed: LocalInstalledModel[];
recommended: LocalRecommendedModel[];
default_ref?: string;
default_provider_name?: string;
default_model?: string;
}

export interface LocalSpeedTestResult {
ok: boolean;
name?: string;
provider_name?: string;
latency_ms?: number;
ttft_ms?: number | null;
tokens?: number | null;
tokens_per_sec?: number | null;
error?: string | null;
next_step?: string;
action?: string;
}

export interface LocalDefaultResult {
ok: boolean;
action?: string;
name?: string | null;
provider_name?: string;
ref?: string;
preferred_model?: string;
error?: string | null;
next_step?: string;
}

export interface LocalRuntimeResult {
Expand Down Expand Up @@ -115,4 +144,24 @@ export const localModelsApi = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
speedTest: (name: string, init?: RequestInit) =>
request<LocalSpeedTestResult>("/local-models/speed-test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
...init,
}),
setDefault: (name: string) =>
request<LocalDefaultResult>("/local-models/default", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
clearDefault: (name?: string) =>
request<LocalDefaultResult>(
name
? `/local-models/default?name=${encodeURIComponent(name)}`
: "/local-models/default",
{ method: "DELETE" },
),
};
17 changes: 17 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3133,6 +3133,23 @@
"localRegisterDone": "Registered {{name}} for chat",
"localRegisterFailed": "Could not register this file",
"localRegisterManual": "Needs a manual GGUF conversion — FreeOS will not invent one",
"localSpeedTest": "Speed test",
"localSpeedCancel": "Cancel test",
"localSpeedNeedRuntime": "Start Ollama first, then run a speed test.",
"localSpeedSuccess": "{{name}} replied in {{latency}} ms",
"localSpeedFailed": "Speed test failed",
"localSpeedCancelled": "Speed test cancelled",
"localSpeedTimeout": "Speed test timed out after 45 seconds. Start Ollama, or try a smaller model.",
"localSpeedLatency": "{{ms}} ms",
"localSpeedTtft": "first token {{ms}} ms",
"localSpeedTps": "{{tps}} tok/s",
"localSetDefault": "Set as default",
"localClearDefault": "Clear default",
"localDefaultBadge": "Default",
"localDefaultSet": "{{name}} is now the default. New chats will use it.",
"localDefaultCleared": "Default model cleared",
"localDefaultFailed": "Could not update the default model",
"localDefaultNeedRegister": "Register this local model before setting it as the default.",
"localOllamaManaged": "Managed by Ollama",
"localDownloadConfirmTitle": "Confirm model download",
"localDownloadConfirmOnnx": "Download {{name}} (about {{size}}). This may take a few minutes.",
Expand Down
17 changes: 17 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -3131,6 +3131,23 @@
"localRegisterDone": "已注册 {{name}},可在对话中切换",
"localRegisterFailed": "无法注册该文件",
"localRegisterManual": "需要手动转为 GGUF,FreeOS 不会代为转换",
"localSpeedTest": "测速",
"localSpeedCancel": "取消测速",
"localSpeedNeedRuntime": "请先启动 Ollama,再测速。",
"localSpeedSuccess": "{{name}} 已响应,耗时 {{latency}} ms",
"localSpeedFailed": "测速失败",
"localSpeedCancelled": "已取消测速",
"localSpeedTimeout": "测速超时(45 秒)。请确认 Ollama 已启动,或换更小的模型。",
"localSpeedLatency": "{{ms}} ms",
"localSpeedTtft": "首字 {{ms}} ms",
"localSpeedTps": "{{tps}} tok/s",
"localSetDefault": "设为默认",
"localClearDefault": "取消默认",
"localDefaultBadge": "默认",
"localDefaultSet": "已将 {{name}} 设为默认模型。新对话会优先使用它。",
"localDefaultCleared": "已取消默认模型",
"localDefaultFailed": "无法更新默认模型",
"localDefaultNeedRegister": "请先注册该本机模型,再设为默认。",
"localOllamaManaged": "由 Ollama 管理",
"localDownloadConfirmTitle": "确认下载模型",
"localDownloadConfirmOnnx": "即将下载 {{name}}(约 {{size}})。下载可能需要几分钟,请保持网络畅通。",
Expand Down
25 changes: 17 additions & 8 deletions dashboard/src/pages/Chat/hooks/useChatComposerResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,16 +284,23 @@ export function useChatComposerResources(

useEffect(() => {
let cancelled = false;
void preferencesApi
.get()
.then((preferences) => {
if (cancelled) return;
setPreferredModel(preferences.preferred_model || null);
setModelReasoning(preferences.model_reasoning || {});
})
.catch(() => undefined);
const loadPreferences = () => {
void preferencesApi
.get()
.then((preferences) => {
if (cancelled) return;
setPreferredModel(preferences.preferred_model || null);
setModelReasoning(preferences.model_reasoning || {});
})
.catch(() => undefined);
};
loadPreferences();
window.addEventListener("focus", loadPreferences);
window.addEventListener(MODELS_CHANGED_EVENT, loadPreferences);
return () => {
cancelled = true;
window.removeEventListener("focus", loadPreferences);
window.removeEventListener(MODELS_CHANGED_EVENT, loadPreferences);
};
}, []);

Expand All @@ -313,9 +320,11 @@ export function useChatComposerResources(
loadActiveModel();
const onFocus = () => loadActiveModel();
window.addEventListener("focus", onFocus);
window.addEventListener(MODELS_CHANGED_EVENT, loadActiveModel);
return () => {
cancelled = true;
window.removeEventListener("focus", onFocus);
window.removeEventListener(MODELS_CHANGED_EVENT, loadActiveModel);
};
}, []);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const startOllama = 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();

vi.mock("../../../../api/modules/localModels", () => ({
localModelsApi: {
Expand All @@ -20,6 +23,9 @@ vi.mock("../../../../api/modules/localModels", () => ({
getLatestScan: vi.fn(),
cancelScan: vi.fn(),
register: (...args: unknown[]) => register(...args),
speedTest: (...args: unknown[]) => speedTest(...args),
setDefault: (...args: unknown[]) => setDefault(...args),
clearDefault: (...args: unknown[]) => clearDefault(...args),
},
}));

Expand Down Expand Up @@ -108,6 +114,63 @@ describe("<LocalHardwarePanel />", () => {
await waitFor(() => expect(startScan).toHaveBeenCalled());
});

it("offers speed test and set-default on a registered model", async () => {
probe.mockResolvedValue({
...installedStopped,
hardware: { ...installedStopped.hardware, ollama_reachable: true },
installed: [
{
name: "tiny",
path: "",
size: 2048,
source: "ollama",
registered: true,
is_default: false,
},
],
});
speedTest.mockResolvedValue({
ok: true,
latency_ms: 90,
ttft_ms: 30,
tokens_per_sec: 12,
});
setDefault.mockResolvedValue({ ok: true, name: "tiny" });
renderPanel();
await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument());
expect(screen.getByText("models.localSpeedTest")).toBeInTheDocument();
expect(screen.getByText("models.localSetDefault")).toBeInTheDocument();
await userEvent.click(screen.getByText("models.localSpeedTest"));
await waitFor(() =>
expect(speedTest).toHaveBeenCalledWith("tiny", expect.anything()),
);
await userEvent.click(screen.getByText("models.localSetDefault"));
await waitFor(() => expect(setDefault).toHaveBeenCalledWith("tiny"));
});

it("shows a default badge and can clear it", async () => {
probe.mockResolvedValue({
...installedStopped,
installed: [
{
name: "tiny",
path: "",
size: 2048,
source: "ollama",
registered: true,
is_default: true,
},
],
});
clearDefault.mockResolvedValue({ ok: true });
renderPanel();
await waitFor(() =>
expect(screen.getByText("models.localDefaultBadge")).toBeInTheDocument(),
);
await userEvent.click(screen.getByText("models.localClearDefault"));
await waitFor(() => expect(clearDefault).toHaveBeenCalledWith("tiny"));
});

it("notifies the chat picker after a successful register", async () => {
const onSaved = vi.fn();
const heard = vi.fn();
Expand Down
Loading
Loading