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 / GGUF 现在会出现在对话区模型选择器里,并走本机 `127.0.0.1:11434` 调用。注册会补齐 Ollama 供应商默认值、打开服务开关、热加载对话列表;Ollama 未启动时给出可操作错误,而不是静默失败。
- 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。
- 组织页默认只显示本机 openXYOS 嵌入和顶部蓝色状态栏;「启用组织模块」「组织OS管理」在状态栏右下。互生长导入/导出、源码下载、循环时间线等收到「组织OS管理」。iframe 高度等于可见预览区,openXYOS 弹窗按 iframe 视口水平垂直居中。
- 嵌入本机 openXYOS 登录不再因 CORS 漏掉 `http://127.0.0.1:3780` 而返回笼统的「服务器内部错误」。启动环境合并 sidecar 自源;无演示种子时写入本地 demo 账号,保证隔空第一台电脑能登录。
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/api/modules/localModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface LocalRuntimeResult {
size?: number;
source?: string;
provider_name?: string;
registered?: boolean;
}

export interface LocalScanJob {
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,8 @@
"recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.",
"timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.",
"provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.",
"model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models."
"model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.",
"local_runtime": "The local Ollama runtime is not reachable. Start Ollama from Settings → Models, then retry. If it is already running, confirm it is listening on port 11434."
},
"chat": {
"sharedExpert": {
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,8 @@
"recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。",
"timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。",
"provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。",
"model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。"
"model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。",
"local_runtime": "本机 Ollama 未就绪或无法连接。请到「设置 → 模型」启动 Ollama 后再试。若已启动,请确认它在 11434 端口监听。"
},
"chat": {
"sharedExpert": {
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/pages/Chat/hooks/useChatComposerResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "../../../api/modules/knowledgeBases";
import type { ResolvedModel } from "../../../api/types";
import { CONNECTORS_CHANGED_EVENT } from "../../Agent/Connectors/customMcpUtils";
import { MODELS_CHANGED_EVENT } from "../../Settings/Models/modelsChanged";
import { useCurrentUser } from "../../../hooks/useCurrentUser";
import { useAgent } from "../../../context/AgentContext";
import { activeModelToRef } from "./useChatContextWindow";
Expand Down Expand Up @@ -273,9 +274,11 @@ export function useChatComposerResources(
loadModels();
const onFocus = () => loadModels();
window.addEventListener("focus", onFocus);
window.addEventListener(MODELS_CHANGED_EVENT, loadModels);
return () => {
cancelled = true;
window.removeEventListener("focus", onFocus);
window.removeEventListener(MODELS_CHANGED_EVENT, loadModels);
};
}, []);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";

const probe = vi.fn();
const startOllama = vi.fn();
Expand Down Expand Up @@ -76,26 +77,48 @@ beforeEach(() => {
});
});

function renderPanel(props: { onSaved?: () => void | Promise<void> } = {}) {
return render(
<MemoryRouter>
<LocalHardwarePanel {...props} />
</MemoryRouter>,
);
}

describe("<LocalHardwarePanel />", () => {
it("shows Start Ollama when the binary exists but the API is down", async () => {
render(<LocalHardwarePanel />);
renderPanel();
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 />);
renderPanel();
await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument());
expect(screen.getByText("D:\\\\models\\\\tiny.gguf")).toBeInTheDocument();
expect(screen.getByText("models.localRegister")).toBeInTheDocument();
});

it("starts a local weight search", async () => {
render(<LocalHardwarePanel />);
renderPanel();
await waitFor(() => expect(probe).toHaveBeenCalled());
await userEvent.click(screen.getByText("models.localSearchModels"));
await waitFor(() => expect(startScan).toHaveBeenCalled());
});

it("notifies the chat picker after a successful register", async () => {
const onSaved = vi.fn();
const heard = vi.fn();
register.mockResolvedValue({ ok: true, name: "tiny" });
window.addEventListener("octop:models-changed", heard);
renderPanel({ onSaved });
await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument());
await userEvent.click(screen.getByText("models.localRegister"));
await waitFor(() => expect(register).toHaveBeenCalled());
expect(heard).toHaveBeenCalled();
expect(onSaved).toHaveBeenCalled();
window.removeEventListener("octop:models-changed", heard);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,32 +26,10 @@ import {
canPickDesktopFolder,
pickDesktopFolder,
} from "../../../../utils/desktopFolder";
import { providerApi } from "../../../../api/modules/provider";
import { message } from "../../../../utils/antdMessage";
import { apiErrorMessage } from "../../../../utils/apiError";
import { CONVERSATION_LIST_PATH } from "../../../../layouts/conversationHome";
import { isOllamaProviderRow } from "../presetUtils";

async function registerOllamaModel(name: string): Promise<boolean> {
const providers = await providerApi.listProviders();
const ollama = providers.find(
(row) =>
row.id === "ollama" ||
isOllamaProviderRow({
name: row.name,
base_url: row.current_base_url,
api_key: row.current_api_key,
}),
);
if (!ollama) return false;
const already = [
...(ollama.models ?? []),
...(ollama.extra_models ?? []),
].some((model) => model.id === name || model.name === name);
if (already) return true;
await providerApi.addModel(ollama.id, { id: name, name });
return true;
}
import { notifyModelsChanged } from "../modelsChanged";

function mergeModels(
...groups: Array<LocalInstalledModel[] | undefined>
Expand All @@ -77,7 +55,11 @@ function canRegister(item: LocalInstalledModel): boolean {
);
}

export function LocalHardwarePanel() {
export function LocalHardwarePanel({
onSaved,
}: {
onSaved?: () => void | Promise<void>;
} = {}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [probe, setProbe] = useState<LocalProbe | null>(null);
Expand Down Expand Up @@ -199,18 +181,14 @@ export function LocalHardwarePanel() {
await refresh();
return;
}
let registered = false;
try {
registered = await registerOllamaModel(name);
} catch {
registered = false;
}
notifyModelsChanged();
await onSaved?.();
message.success(
t(
registered
? "models.localInstallRegistered"
: "models.localInstallNeedProvider",
{ name },
result.registered === false
? "models.localInstallNeedProvider"
: "models.localInstallRegistered",
{ name: result.name || name },
),
);
await refresh();
Expand Down Expand Up @@ -338,6 +316,8 @@ export function LocalHardwarePanel() {
showRuntimeError(result, t("models.localRegisterFailed"));
return;
}
notifyModelsChanged();
await onSaved?.();
message.success(t("models.localRegisterDone", { name: result.name }));
await refresh();
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ProviderPreset,
ProviderRow,
} from "../../useProviders";
import { MODELS_CHANGED_EVENT } from "../../modelsChanged";
import { presetLogoId } from "../../presetUtils";
import { ProviderConfigModal } from "../modals/ProviderConfigModal";
import styles from "../../index.module.less";
Expand Down Expand Up @@ -71,36 +72,41 @@ export function LocalServiceCard({

useEffect(() => {
let cancelled = false;
void (async () => {
try {
if (isOnnx) {
const st = await onnxModelApi.getStatus();
if (!cancelled) {
setServiceEnabled(st.enabled);
setServiceRunning(st.ready || st.enabled);
setDepsAvailable(st.deps_available !== false);
setDepsInstallFailed(false);
const refreshService = () => {
void (async () => {
try {
if (isOnnx) {
const st = await onnxModelApi.getStatus();
if (!cancelled) {
setServiceEnabled(st.enabled);
setServiceRunning(st.ready || st.enabled);
setDepsAvailable(st.deps_available !== false);
setDepsInstallFailed(false);
}
} else {
const st = await ollamaModelApi.getService();
if (!cancelled) {
setServiceEnabled(st.enabled);
setServiceRunning(st.running);
setDepsAvailable(true);
setDepsInstallFailed(false);
}
}
} else {
const st = await ollamaModelApi.getService();
} catch {
if (!cancelled) {
setServiceEnabled(st.enabled);
setServiceRunning(st.running);
setDepsAvailable(true);
setDepsInstallFailed(false);
setServiceEnabled(false);
setServiceRunning(false);
}
}
} catch {
if (!cancelled) {
setServiceEnabled(false);
setServiceRunning(false);
}
}
})();
})();
};
refreshService();
window.addEventListener(MODELS_CHANGED_EVENT, refreshService);
return () => {
cancelled = true;
window.removeEventListener(MODELS_CHANGED_EVENT, refreshService);
};
}, [isOnnx, provider?.id]);
}, [isOnnx, provider?.id, provider?.enabled]);

const ensureProvider = async (): Promise<ProviderRow> => {
if (row) return row;
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/pages/Settings/Models/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export default function ModelsPage() {

<Divider style={{ margin: "24px 0" }} />

{showLocalTab ? <LocalHardwarePanel /> : null}
{showLocalTab ? <LocalHardwarePanel onSaved={fetchAll} /> : null}

{showPresetSection && (
<>
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/pages/Settings/Models/modelsChanged.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const MODELS_CHANGED_EVENT = "octop:models-changed";

export function notifyModelsChanged(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(MODELS_CHANGED_EVENT));
}
41 changes: 41 additions & 0 deletions dashboard/src/pages/Settings/Models/presetUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { findConfiguredProvider } from "./presetUtils";
import type { ProviderPreset, ProviderRow } from "./useProviders";

const ollamaPreset = {
id: "ollama",
name: "Ollama (Local)",
} as ProviderPreset;

function row(
partial: Partial<ProviderRow> & Pick<ProviderRow, "name">,
): ProviderRow {
return {
id: 1,
kind: "openai",
base_url: null,
api_key: null,
models: [],
note: null,
enabled: true,
...partial,
};
}

describe("findConfiguredProvider", () => {
it("matches the Ollama preset by display name or id", () => {
const named = row({ name: "Ollama (Local)" });
expect(findConfiguredProvider(ollamaPreset, [named])).toEqual(named);
const short = row({ name: "ollama" });
expect(findConfiguredProvider(ollamaPreset, [short])).toEqual(short);
});

it("matches a local Ollama row that only shares the 11434 endpoint", () => {
const local = row({
name: "Desktop Llama",
base_url: "http://127.0.0.1:11434/v1",
api_key: "ollama",
});
expect(findConfiguredProvider(ollamaPreset, [local])).toEqual(local);
});
});
24 changes: 17 additions & 7 deletions dashboard/src/pages/Settings/Models/presetUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,6 @@ export function presetLogoId(preset: ProviderPreset): string {
return preset.logo_id || preset.id;
}

export function findConfiguredProvider(
preset: ProviderPreset,
providers: ProviderRow[],
): ProviderRow | undefined {
return providers.find((p) => p.name === preset.name || p.name === preset.id);
}

export function groupPresets(presets: ProviderPreset[]): {
grouped: PresetGroup[];
ungrouped: ProviderPreset[];
Expand Down Expand Up @@ -167,3 +160,20 @@ export function isLocalProviderRow(provider: {
}): boolean {
return isOnnxProviderRow(provider) || isOllamaProviderRow(provider);
}

export function findConfiguredProvider(
preset: ProviderPreset,
providers: ProviderRow[],
): ProviderRow | undefined {
const exact = providers.find(
(p) => p.name === preset.name || p.name === preset.id,
);
if (exact) return exact;
if (preset.id === "ollama") {
return providers.find((p) => isOllamaProviderRow(p));
}
if (preset.id === "onnx") {
return providers.find((p) => isOnnxProviderRow(p));
}
return undefined;
}
9 changes: 9 additions & 0 deletions dashboard/src/utils/chatStreamError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ describe("classifyChatStreamError", () => {
});
});

it("classifies an unreachable local Ollama runtime", () => {
const msg = "Failed to connect to ollama at http://127.0.0.1:11434";
expect(classifyChatStreamError(msg)).toBe("stream_errors.local_runtime");
expect(chatStreamErrorAction(msg)).toEqual({
path: "/models",
labelKey: "models.useInChat",
});
});

it("classifies HTTP 5xx as provider_unavailable", () => {
expect(classifyChatStreamError("HTTP 503: service overloaded")).toBe(
"stream_errors.provider_unavailable",
Expand Down
Loading
Loading