@next-open-ai/openclawx 0.8.48 → 0.8.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/apps/desktop/renderer/dist/assets/index-M5VGUUpo.js +93 -0
  2. package/apps/desktop/renderer/dist/assets/{index-BHY1xIZQ.css → index-y8oE2q_u.css} +1 -1
  3. package/apps/desktop/renderer/dist/index.html +2 -2
  4. package/dist/cli/cli.js +107 -0
  5. package/dist/core/agent/agent-manager.js +4 -0
  6. package/dist/core/config/desktop-config.d.ts +2 -1
  7. package/dist/core/config/desktop-config.js +92 -26
  8. package/dist/core/local-llm-server/download-model.d.ts +16 -0
  9. package/dist/core/local-llm-server/download-model.js +37 -0
  10. package/dist/core/local-llm-server/index.js +26 -5
  11. package/dist/core/local-llm-server/llm-context.d.ts +9 -4
  12. package/dist/core/local-llm-server/llm-context.js +35 -14
  13. package/dist/core/local-llm-server/model-resolve.d.ts +8 -1
  14. package/dist/core/local-llm-server/model-resolve.js +44 -12
  15. package/dist/core/local-llm-server/server.js +11 -12
  16. package/dist/core/local-llm-server/start-from-config.d.ts +5 -0
  17. package/dist/core/local-llm-server/start-from-config.js +50 -0
  18. package/dist/core/mcp/transport/stdio.d.ts +6 -0
  19. package/dist/core/mcp/transport/stdio.js +107 -27
  20. package/dist/core/memory/local-embedding-llama.js +2 -4
  21. package/dist/gateway/methods/agent-chat.js +9 -0
  22. package/dist/gateway/server.js +8 -51
  23. package/dist/server/bootstrap.d.ts +1 -0
  24. package/dist/server/bootstrap.js +3 -0
  25. package/dist/server/config/config.controller.d.ts +25 -2
  26. package/dist/server/config/config.controller.js +62 -12
  27. package/dist/server/config/config.service.d.ts +4 -1
  28. package/dist/server/config/config.service.js +62 -9
  29. package/dist/server/config/local-models.service.d.ts +16 -1
  30. package/dist/server/config/local-models.service.js +78 -46
  31. package/package.json +1 -1
  32. package/presets/preset-agents.json +6 -2
  33. package/presets/preset-config.json +24 -6
  34. package/apps/desktop/renderer/dist/assets/index-DQxlVuBe.js +0 -93
  35. package/presets/workspaces/finance-expert/skills/akshare-helper/SKILL.md +0 -9
@@ -11,8 +11,8 @@
11
11
  <link
12
12
  href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap"
13
13
  rel="stylesheet">
14
- <script type="module" crossorigin src="/assets/index-DQxlVuBe.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-BHY1xIZQ.css">
14
+ <script type="module" crossorigin src="/assets/index-M5VGUUpo.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-y8oE2q_u.css">
16
16
  </head>
17
17
 
18
18
  <body>
package/dist/cli/cli.js CHANGED
@@ -6,6 +6,9 @@ import { Command } from "commander";
6
6
  import { getOpenbotAgentDir } from "../core/agent/agent-dir.js";
7
7
  import { run } from "../core/agent/run.js";
8
8
  import { loadDesktopAgentConfig, getBoundAgentIdForCli, setProviderApiKey, setDefaultModel, getDesktopConfigList, syncDesktopConfigToModelsJson, ensureDesktopConfigInitialized, } from "../core/config/desktop-config.js";
9
+ import { downloadModel, DEFAULT_LLM_MODEL_URI, } from "../core/local-llm-server/download-model.js";
10
+ import { startLocalLlmServer, stopLocalLlmServer, } from "../core/local-llm-server/index.js";
11
+ import { LOCAL_LLM_CACHE_DIR, isModelFileInCache, toModelPathForStart, } from "../core/local-llm-server/model-resolve.js";
9
12
  import { writeGatewayPid, removeGatewayPidFile, serviceInstall, serviceUninstall, serviceStop, } from "./service.js";
10
13
  import { installExtension, listExtensions, uninstallExtension } from "./extension-cmd.js";
11
14
  const require = createRequire(import.meta.url);
@@ -218,6 +221,110 @@ extensionCmd
218
221
  .action((pkg) => {
219
222
  uninstallExtension(pkg);
220
223
  });
224
+ // 本地模型:下载与启动服务
225
+ const localCmd = program
226
+ .command("local")
227
+ .description("下载本地 GGUF 模型与启动本地 LLM 服务");
228
+ localCmd
229
+ .command("download")
230
+ .description("下载推荐模型到 ~/.openbot/.cached_models/,不指定模型时下载 Qwen3-4B")
231
+ .argument("[modelUri]", "模型 URI(如 hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf),不传则下载 Qwen3-4B")
232
+ .option("--mirror", "使用国内镜像 hf-mirror.com 下载")
233
+ .action(async (modelUri, opts) => {
234
+ const uri = (modelUri || "").trim() || DEFAULT_LLM_MODEL_URI;
235
+ console.log(`[openbot] 下载模型: ${uri}`);
236
+ if (opts.mirror)
237
+ console.log("[openbot] 使用国内镜像 hf-mirror.com");
238
+ try {
239
+ const path = await downloadModel(uri, {
240
+ useMirror: opts.mirror,
241
+ onProgress: (p) => {
242
+ const percent = p.totalSize ? Math.round((p.downloadedSize / p.totalSize) * 100) : (p.percent ?? 0);
243
+ const mb = (p.downloadedSize / 1024 / 1024).toFixed(1);
244
+ const totalMb = p.totalSize ? (p.totalSize / 1024 / 1024).toFixed(1) : "?";
245
+ process.stderr.write(`\r[openbot] 下载中 ${percent}% (${mb} / ${totalMb} MB)`);
246
+ },
247
+ });
248
+ console.log(`\n[openbot] 已保存: ${path}`);
249
+ }
250
+ catch (err) {
251
+ const msg = err instanceof Error ? err.message : String(err);
252
+ console.error("\n[openbot] 下载失败:", msg);
253
+ process.exit(1);
254
+ }
255
+ });
256
+ localCmd
257
+ .command("start")
258
+ .description("启动本地 LLM 服务(至少指定 --llm 或 --embedding 之一)")
259
+ .option("--llm <uriOrFile>", "LLM 模型:hf: URI 或已下载文件名,不传则使用桌面缺省模型")
260
+ .option("--embedding <uriOrFile>", "Embedding 模型:hf: URI 或已下载文件名(可选)")
261
+ .option("--context-size <n>", "上下文长度(token 数),默认 32768 或环境变量 LOCAL_LLM_CONTEXT_MAX", (v) => parseInt(v, 10) || 32768)
262
+ .option("--port <port>", "服务端口", "11435")
263
+ .action(async (opts) => {
264
+ let llmPath;
265
+ let embPath;
266
+ if (opts.llm?.trim()) {
267
+ const llmArg = opts.llm.trim();
268
+ if (!llmArg.startsWith("hf:") && !isModelFileInCache(llmArg, LOCAL_LLM_CACHE_DIR)) {
269
+ console.error("[openbot] 模型未下载或路径不存在,请先执行: openbot local download [modelUri]");
270
+ process.exit(1);
271
+ }
272
+ llmPath = toModelPathForStart(llmArg, LOCAL_LLM_CACHE_DIR);
273
+ }
274
+ else {
275
+ const agentConfig = await loadDesktopAgentConfig("default");
276
+ const defaultModel = agentConfig?.model?.trim();
277
+ if (defaultModel) {
278
+ llmPath = toModelPathForStart(defaultModel, LOCAL_LLM_CACHE_DIR);
279
+ if (!isModelFileInCache(defaultModel, LOCAL_LLM_CACHE_DIR)) {
280
+ console.error("[openbot] 缺省模型未下载,请先执行: openbot local download");
281
+ process.exit(1);
282
+ }
283
+ }
284
+ }
285
+ if (opts.embedding?.trim()) {
286
+ const embArg = opts.embedding.trim();
287
+ if (!embArg.startsWith("hf:") && !isModelFileInCache(embArg, LOCAL_LLM_CACHE_DIR)) {
288
+ console.error("[openbot] Embedding 模型未下载或路径不存在,请先执行: openbot local download <embedding-uri>");
289
+ process.exit(1);
290
+ }
291
+ embPath = toModelPathForStart(embArg, LOCAL_LLM_CACHE_DIR);
292
+ }
293
+ if (!llmPath && !embPath) {
294
+ console.error("[openbot] 请至少指定 --llm 或 --embedding,或先配置桌面缺省模型");
295
+ process.exit(1);
296
+ }
297
+ const contextSize = opts.contextSize ??
298
+ (process.env.LOCAL_LLM_CONTEXT_MAX ? parseInt(process.env.LOCAL_LLM_CONTEXT_MAX, 10) : undefined) ??
299
+ 32768;
300
+ const port = parseInt(opts.port || "11435", 10);
301
+ try {
302
+ const handle = await startLocalLlmServer({
303
+ port,
304
+ llmModelPath: llmPath,
305
+ embeddingModelPath: embPath,
306
+ contextSize,
307
+ });
308
+ console.log(`[openbot] 本地模型服务已启动: ${handle.baseUrl}`);
309
+ console.log("[openbot] 按 Ctrl+C 停止服务");
310
+ await new Promise((resolve) => {
311
+ process.on("SIGINT", () => {
312
+ stopLocalLlmServer();
313
+ resolve();
314
+ });
315
+ process.on("SIGTERM", () => {
316
+ stopLocalLlmServer();
317
+ resolve();
318
+ });
319
+ });
320
+ process.exit(0);
321
+ }
322
+ catch (err) {
323
+ const msg = err instanceof Error ? err.message : String(err);
324
+ console.error("[openbot] 启动失败:", msg);
325
+ process.exit(1);
326
+ }
327
+ });
221
328
  (async () => {
222
329
  await ensureDesktopConfigInitialized();
223
330
  await program.parseAsync(process.argv);
@@ -230,6 +230,10 @@ For downloads, provide either a direct URL or a selector to click.`;
230
230
  if (apiKey) {
231
231
  authStorage.setRuntimeApiKey(provider, apiKey);
232
232
  }
233
+ // local 无需真实 API Key,显式设置占位凭证,避免 SDK 走默认凭证链(如 AWS)导致 "Could not load credentials from any providers"
234
+ if (provider === "local") {
235
+ authStorage.setRuntimeApiKey("local", process.env.OPENAI_API_KEY || "local");
236
+ }
233
237
  if (await authStorage.hasAuth(provider)) {
234
238
  const key = await authStorage.getApiKey(provider);
235
239
  if (key) {
@@ -225,7 +225,8 @@ export declare function getDesktopConfigList(): Promise<DesktopConfigList>;
225
225
  */
226
226
  export declare function ensureProviderSupportFile(): Promise<void>;
227
227
  /**
228
- * CLI / Gateway 运行时调用,确保 config.json、provider-support.json、agents.json 均完成初始化。
228
+ * CLI / Gateway 运行时调用,确保 config.json、provider-support.json、agents.json 均完成初始化,
229
+ * 并同步到 agent 目录 models.json,供 pi ModelRegistry 解析 local 等模型与凭证。
229
230
  */
230
231
  export declare function ensureDesktopConfigInitialized(): Promise<void>;
231
232
  /**
@@ -218,6 +218,8 @@ export async function loadDesktopAgentConfig(agentId) {
218
218
  model = configured.modelId;
219
219
  }
220
220
  }
221
+ /** 是否从当前智能体自己的配置得到了模型(有 modelItemCode 或 provider/model);若否,则使用的是全局默认 */
222
+ let agentHadOwnModel = false;
221
223
  let workspaceName = resolvedAgentId;
222
224
  let mcpServers;
223
225
  let mcpMaxResultTokens;
@@ -256,19 +258,28 @@ export async function loadDesktopAgentConfig(agentId) {
256
258
  if (configured) {
257
259
  provider = configured.provider;
258
260
  model = configured.modelId;
261
+ agentHadOwnModel = true;
259
262
  }
260
263
  else {
261
- if (agent.provider)
264
+ if (agent.provider) {
262
265
  provider = agent.provider;
263
- if (agent.model)
266
+ agentHadOwnModel = true;
267
+ }
268
+ if (agent.model) {
264
269
  model = agent.model;
270
+ agentHadOwnModel = true;
271
+ }
265
272
  }
266
273
  }
267
274
  else {
268
- if (agent.provider)
275
+ if (agent.provider) {
269
276
  provider = agent.provider;
270
- if (agent.model)
277
+ agentHadOwnModel = true;
278
+ }
279
+ if (agent.model) {
271
280
  model = agent.model;
281
+ agentHadOwnModel = true;
282
+ }
272
283
  }
273
284
  }
274
285
  }
@@ -276,6 +287,11 @@ export async function loadDesktopAgentConfig(agentId) {
276
287
  // ignore
277
288
  }
278
289
  }
290
+ // 本地 LLM 可用且当前智能体未配置自己的模型时,使用本地推理作为缺省,使所有智能体“拥有”该配置
291
+ if (!agentHadOwnModel && process.env.LOCAL_LLM_BASE_URL?.trim()) {
292
+ provider = "local";
293
+ model = "local-llm";
294
+ }
279
295
  const provConfig = config.providers?.[provider];
280
296
  const apiKey = provConfig?.apiKey && typeof provConfig.apiKey === "string" && provConfig.apiKey.trim()
281
297
  ? provConfig.apiKey.trim()
@@ -629,32 +645,54 @@ export async function ensureProviderSupportFile() {
629
645
  await writeFile(path, JSON.stringify(presetProviders, null, 2), "utf-8");
630
646
  }
631
647
  }
632
- /** config.json 不存在则用 preset-config.json 初始化,若存在则浅合并补充新基础键值 */
648
+ /** 预装本地推理缺省:推荐列表第一个 LLM(Qwen3-4B)对应的本地文件名,与 modelUriToFilename 一致 */
649
+ const DEFAULT_LOCAL_LLM_MODEL_ID = "hf_Qwen_Qwen3-4B-GGUF_Qwen3-4B-Q4_K_M.gguf";
650
+ const DEFAULT_LOCAL_MODEL_ITEM_CODE = "local-qwen3-4b";
651
+ /** 代码内建默认:local provider + 本地 Qwen3-4B,首次与合并时优先保证存在 */
652
+ const BUILTIN_DEFAULT_CONFIG = {
653
+ defaultProvider: "local",
654
+ defaultModel: DEFAULT_LOCAL_LLM_MODEL_ID,
655
+ defaultModelItemCode: DEFAULT_LOCAL_MODEL_ITEM_CODE,
656
+ defaultAgentId: DEFAULT_AGENT_ID,
657
+ maxAgentSessions: DEFAULT_MAX_AGENT_SESSIONS,
658
+ providers: {
659
+ local: { baseUrl: "http://127.0.0.1:11435/v1" },
660
+ },
661
+ configuredModels: [
662
+ {
663
+ provider: "local",
664
+ modelId: DEFAULT_LOCAL_LLM_MODEL_ID,
665
+ type: "llm",
666
+ alias: "Qwen3 4B Q4_K_M",
667
+ modelItemCode: DEFAULT_LOCAL_MODEL_ITEM_CODE,
668
+ },
669
+ {
670
+ provider: "local",
671
+ modelId: "hf_ggml-org_embeddinggemma-300M-GGUF_embeddinggemma-300M-Q8_0.gguf",
672
+ type: "embedding",
673
+ alias: "EmbeddingGemma 300M Q8 (768维)",
674
+ modelItemCode: "local-embeddinggemma-300m",
675
+ },
676
+ ],
677
+ };
678
+ /** 若 config.json 不存在则用 preset-config.json 初始化,若存在则浅合并补充新基础键值。预装 local provider + 本地 Qwen3-4B 模型并设为缺省;preset 与代码默认合并,保证 local 一定存在。 */
633
679
  async function ensureConfigJsonInitialized() {
634
680
  const presetPath = join(getPresetsDir(), "preset-config.json");
635
- let presetConfig = {
636
- defaultProvider: "ollama",
637
- defaultModel: "qwen3:4b",
638
- defaultAgentId: DEFAULT_AGENT_ID,
639
- maxAgentSessions: DEFAULT_MAX_AGENT_SESSIONS,
640
- providers: {
641
- ollama: { baseUrl: "http://localhost:11434/v1" },
642
- },
643
- configuredModels: [
644
- {
645
- provider: "ollama",
646
- modelId: "qwen3:4b",
647
- type: "llm",
648
- alias: "Qwen3 4B (本地)",
649
- modelItemCode: "ollama:qwen3:4b",
650
- },
651
- ],
652
- };
681
+ let presetConfig = { ...BUILTIN_DEFAULT_CONFIG };
653
682
  if (existsSync(presetPath)) {
654
683
  try {
655
684
  const data = JSON.parse(await readFile(presetPath, "utf-8"));
656
- if (data.config)
657
- presetConfig = data.config;
685
+ if (data.config && typeof data.config === "object") {
686
+ presetConfig = { ...BUILTIN_DEFAULT_CONFIG, ...data.config };
687
+ presetConfig.providers = { ...BUILTIN_DEFAULT_CONFIG.providers, ...(presetConfig.providers || {}) };
688
+ const hasLocalModel = (presetConfig.configuredModels || []).some((m) => m?.provider === "local" && (m?.modelId === DEFAULT_LOCAL_LLM_MODEL_ID || m?.modelItemCode === DEFAULT_LOCAL_MODEL_ITEM_CODE));
689
+ if (!hasLocalModel) {
690
+ presetConfig.configuredModels = [
691
+ ...(BUILTIN_DEFAULT_CONFIG.configuredModels || []),
692
+ ...(presetConfig.configuredModels || []),
693
+ ];
694
+ }
695
+ }
658
696
  }
659
697
  catch { }
660
698
  }
@@ -723,18 +761,46 @@ async function ensureAgentsJsonInitialized() {
723
761
  }
724
762
  }
725
763
  }
764
+ // 所有未单独配置模型的智能体使用 config 的缺省模型(预装为 local + Qwen3-4B)
765
+ const configPath = join(getDesktopDir(), "config.json");
766
+ if (existsSync(configPath)) {
767
+ try {
768
+ const configRaw = await readFile(configPath, "utf-8");
769
+ const configData = JSON.parse(configRaw);
770
+ const defProvider = configData.defaultProvider?.trim();
771
+ const defModel = configData.defaultModel?.trim();
772
+ const defCode = configData.defaultModelItemCode?.trim();
773
+ if (defProvider && defModel) {
774
+ for (const agent of currentData.agents) {
775
+ const hasOwn = (agent.provider && String(agent.provider).trim()) || (agent.model && String(agent.model).trim()) || (agent.modelItemCode && String(agent.modelItemCode).trim());
776
+ if (!hasOwn) {
777
+ agent.provider = defProvider;
778
+ agent.model = defModel;
779
+ if (defCode)
780
+ agent.modelItemCode = defCode;
781
+ changed = true;
782
+ }
783
+ }
784
+ }
785
+ }
786
+ catch { /* ignore */ }
787
+ }
726
788
  if (changed || !existsSync(agentsPath)) {
727
789
  await writeFile(agentsPath, JSON.stringify(currentData, null, 2), "utf-8");
728
790
  }
729
791
  }
730
792
  /**
731
- * CLI / Gateway 运行时调用,确保 config.json、provider-support.json、agents.json 均完成初始化。
793
+ * CLI / Gateway 运行时调用,确保 config.json、provider-support.json、agents.json 均完成初始化,
794
+ * 并同步到 agent 目录 models.json,供 pi ModelRegistry 解析 local 等模型与凭证。
732
795
  */
733
796
  export async function ensureDesktopConfigInitialized() {
734
797
  ensureDesktopDir();
735
798
  await ensureProviderSupportFile();
736
799
  await ensureConfigJsonInitialized();
737
800
  await ensureAgentsJsonInitialized();
801
+ await syncDesktopConfigToModelsJson().catch((err) => {
802
+ console.warn("[ensureDesktopConfigInitialized] syncDesktopConfigToModelsJson failed:", err);
803
+ });
738
804
  }
739
805
  /**
740
806
  * 取某 provider 在 provider-support 中的第一个 llm 模型 id;若无则返回第一个模型 id。
@@ -0,0 +1,16 @@
1
+ export declare const DEFAULT_LLM_MODEL_URI = "hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf";
2
+ export interface DownloadModelOptions {
3
+ useMirror?: boolean;
4
+ signal?: AbortSignal;
5
+ onProgress?: (p: {
6
+ downloadedSize: number;
7
+ totalSize: number;
8
+ percent: number;
9
+ }) => void;
10
+ }
11
+ /**
12
+ * 下载模型到本地缓存目录。
13
+ * @returns 解析后的本地文件路径
14
+ */
15
+ export declare function downloadModel(modelUri: string, options?: DownloadModelOptions): Promise<string>;
16
+ export declare function getResolvedBasename(modelUri: string): string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 本地模型下载(供 CLI 与 Nest LocalModelsService 复用)。
3
+ * 使用 node-llama-cpp resolveModelFile,缓存目录 ~/.openbot/.cached_models/。
4
+ */
5
+ import { basename } from "node:path";
6
+ import { LOCAL_LLM_CACHE_DIR } from "./model-resolve.js";
7
+ export const DEFAULT_LLM_MODEL_URI = "hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf";
8
+ /**
9
+ * 下载模型到本地缓存目录。
10
+ * @returns 解析后的本地文件路径
11
+ */
12
+ export async function downloadModel(modelUri, options = {}) {
13
+ const { resolveModelFile } = await import("node-llama-cpp");
14
+ const { useMirror = false, signal, onProgress } = options;
15
+ const hfToken = process.env.HF_TOKEN || process.env.HUGGING_FACE_TOKEN;
16
+ const opts = {
17
+ directory: LOCAL_LLM_CACHE_DIR,
18
+ endpoints: {
19
+ huggingFace: useMirror ? "https://hf-mirror.com/" : "https://huggingface.co/",
20
+ },
21
+ };
22
+ if (signal)
23
+ opts.signal = signal;
24
+ if (hfToken)
25
+ opts.headers = { Authorization: `Bearer ${hfToken}` };
26
+ if (onProgress) {
27
+ opts.onProgress = ({ downloadedSize, totalSize }) => {
28
+ const percent = totalSize ? Math.round((downloadedSize / totalSize) * 100) : 0;
29
+ onProgress({ downloadedSize, totalSize, percent });
30
+ };
31
+ }
32
+ const resolved = await resolveModelFile(modelUri, opts);
33
+ return resolved;
34
+ }
35
+ export function getResolvedBasename(modelUri) {
36
+ return basename(modelUri.replace(/^hf:[^/]+\//, "").replace(/\//g, "_"));
37
+ }
@@ -12,15 +12,27 @@ import { fileURLToPath } from "node:url";
12
12
  // ─── 子进程模式 ───────────────────────────────────────────────────────────────
13
13
  async function runChildProcess() {
14
14
  const port = parseInt(process.env.LOCAL_LLM_PORT ?? "11435", 10);
15
- const llmModelPath = process.env.LOCAL_LLM_MODEL ?? "hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf";
16
- const embModelPath = process.env.LOCAL_EMB_MODEL ?? "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
17
- const contextSize = process.env.LOCAL_LLM_CONTEXT_SIZE != null ? parseInt(process.env.LOCAL_LLM_CONTEXT_SIZE, 10) : undefined;
15
+ const llmModelPath = process.env.LOCAL_LLM_MODEL?.trim() || undefined;
16
+ const embModelPath = process.env.LOCAL_EMB_MODEL?.trim() || undefined;
17
+ let contextSize = process.env.LOCAL_LLM_CONTEXT_SIZE != null ? parseInt(process.env.LOCAL_LLM_CONTEXT_SIZE, 10) : undefined;
18
+ if (contextSize == null && process.env.LOCAL_LLM_CONTEXT_MAX != null && String(process.env.LOCAL_LLM_CONTEXT_MAX).trim() !== '') {
19
+ contextSize = parseInt(process.env.LOCAL_LLM_CONTEXT_MAX, 10) || undefined;
20
+ }
21
+ if (!llmModelPath && !embModelPath) {
22
+ console.error("[local-llm] 未指定 LLM 或 Embedding 模型路径,至少需提供一个");
23
+ if (process.send)
24
+ process.send({ type: "error", message: "至少需指定 LOCAL_LLM_MODEL 或 LOCAL_EMB_MODEL" });
25
+ process.exit(1);
26
+ }
18
27
  const { initModels } = await import("./llm-context.js");
19
28
  const { createOpenAICompatServer } = await import("./server.js");
20
29
  try {
21
- await initModels({ llmModelPath, embeddingModelPath: embModelPath, contextSize: contextSize ?? 32768 });
30
+ await initModels({
31
+ ...(llmModelPath ? { llmModelPath } : {}),
32
+ ...(embModelPath ? { embeddingModelPath: embModelPath } : {}),
33
+ contextSize: contextSize ?? 32768,
34
+ });
22
35
  await createOpenAICompatServer(port);
23
- // 通知主进程已就绪
24
36
  if (process.send) {
25
37
  process.send({ type: "ready", port });
26
38
  }
@@ -113,6 +125,15 @@ export async function startLocalLlmServer(opts = {}) {
113
125
  catch { /* ignore */ }
114
126
  },
115
127
  };
128
+ // 子进程意外退出(崩溃、OOM 等)时清理 handle 与 env,避免后续请求继续连已死服务导致 "Connection error"
129
+ const onChildExit = (code, signal) => {
130
+ if (serverHandle)
131
+ serverHandle = null;
132
+ process.env.LOCAL_LLM_START_FAILED = "本地模型服务已退出,请重新点击「启动本地模型服务」";
133
+ delete process.env.LOCAL_LLM_BASE_URL;
134
+ console.warn("[local-llm] 子进程已退出 code=%s signal=%s,请重新启动本地模型服务", code, signal);
135
+ };
136
+ child.on("exit", onChildExit);
116
137
  console.log(`[local-llm] 本地服务就绪: ${serverHandle.baseUrl}`);
117
138
  return serverHandle;
118
139
  }
@@ -1,8 +1,8 @@
1
1
  export interface LlmContextOptions {
2
- /** LLM 推理模型路径或 hf: URI */
3
- llmModelPath: string;
4
- /** Embedding 模型路径或 hf: URI */
5
- embeddingModelPath: string;
2
+ /** LLM 推理模型路径或 hf: URI,可选;不传则仅提供 embedding */
3
+ llmModelPath?: string;
4
+ /** Embedding 模型路径或 hf: URI,可选;不传则仅提供 chat */
5
+ embeddingModelPath?: string;
6
6
  /** GPU layers,-1 表示全部卸载到 GPU(Metal),0 表示纯 CPU */
7
7
  gpuLayers?: number;
8
8
  /** 上下文窗口大小,默认 32768(32K) */
@@ -57,4 +57,9 @@ export declare function chatCompletion(messages: ChatMessage[], tools: ToolDefin
57
57
  * 文本 embedding,返回 L2 归一化向量。
58
58
  */
59
59
  export declare function getEmbedding(text: string): Promise<number[]>;
60
+ /** 是否至少加载了一个模型(LLM 或 Embedding) */
60
61
  export declare function isReady(): boolean;
62
+ /** 是否有 LLM,可提供 chat/completions */
63
+ export declare function isLlmReady(): boolean;
64
+ /** 是否有 Embedding,可提供 embeddings */
65
+ export declare function isEmbeddingReady(): boolean;
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * node-llama-cpp 模型实例管理。
3
- * 同时持有一个 LLM chat 模型和一个 embedding 模型,各自独立上下文。
4
- * 推理和 embedding 请求串行处理(同一模型不支持并发),两个模型之间可并发。
3
+ * 可只加载 LLM、只加载 Embedding、或两者都加载;有一个就启动一个,不因缺另一个而失败。
5
4
  */
6
- import { join } from "node:path";
7
- import { homedir } from "node:os";
5
+ import { LOCAL_LLM_CACHE_DIR } from "./model-resolve.js";
8
6
  let llama = null;
9
7
  let llmModel = null;
10
8
  let embeddingModel = null;
@@ -26,16 +24,30 @@ async function getLlamaInstance(gpuLayers) {
26
24
  export async function initModels(opts) {
27
25
  storedContextSize = opts.contextSize ?? 32768;
28
26
  const { resolveModelFile } = await import("node-llama-cpp");
29
- const cacheDir = join(homedir(), ".cache", "llama");
30
27
  const instance = await getLlamaInstance(opts.gpuLayers);
31
- console.log("[local-llm] 加载 LLM 模型:", opts.llmModelPath);
32
- const llmPath = await resolveModelFile(opts.llmModelPath, cacheDir);
33
- llmModel = await instance.loadModel({ modelPath: llmPath });
34
- console.log("[local-llm] 加载 Embedding 模型:", opts.embeddingModelPath);
35
- const embPath = await resolveModelFile(opts.embeddingModelPath, cacheDir);
36
- embeddingModel = await instance.loadModel({ modelPath: embPath });
37
- embeddingCtx = await embeddingModel.createEmbeddingContext();
38
- console.log("[local-llm] 模型加载完成");
28
+ const cacheDir = LOCAL_LLM_CACHE_DIR;
29
+ if (opts.llmModelPath?.trim()) {
30
+ console.log("[local-llm] 加载 LLM 模型:", opts.llmModelPath);
31
+ const llmPath = await resolveModelFile(opts.llmModelPath, cacheDir);
32
+ llmModel = await instance.loadModel({ modelPath: llmPath });
33
+ }
34
+ else {
35
+ llmModel = null;
36
+ }
37
+ if (opts.embeddingModelPath?.trim()) {
38
+ console.log("[local-llm] 加载 Embedding 模型:", opts.embeddingModelPath);
39
+ const embPath = await resolveModelFile(opts.embeddingModelPath, cacheDir);
40
+ embeddingModel = await instance.loadModel({ modelPath: embPath });
41
+ embeddingCtx = await embeddingModel.createEmbeddingContext();
42
+ }
43
+ else {
44
+ embeddingModel = null;
45
+ embeddingCtx = null;
46
+ }
47
+ console.log("[local-llm] 模型加载完成", {
48
+ llm: !!llmModel,
49
+ embedding: !!embeddingCtx,
50
+ });
39
51
  }
40
52
  /** 将 API 可能传来的 content(string | array 如 [{ type: "text", text: "..." }])规范为 string,避免 node-llama-cpp LlamaText.fromJSON 收到对象抛 "Unknown value type: [object Object]" */
41
53
  function contentToString(content) {
@@ -216,6 +228,15 @@ export async function getEmbedding(text) {
216
228
  const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
217
229
  return vec.map((v) => v / norm);
218
230
  }
231
+ /** 是否至少加载了一个模型(LLM 或 Embedding) */
219
232
  export function isReady() {
220
- return llmModel !== null && embeddingCtx !== null;
233
+ return llmModel !== null || embeddingCtx !== null;
234
+ }
235
+ /** 是否有 LLM,可提供 chat/completions */
236
+ export function isLlmReady() {
237
+ return llmModel !== null;
238
+ }
239
+ /** 是否有 Embedding,可提供 embeddings */
240
+ export function isEmbeddingReady() {
241
+ return embeddingCtx !== null;
221
242
  }
@@ -2,6 +2,7 @@ export declare const LOCAL_LLM_CACHE_DIR: string;
2
2
  /**
3
3
  * 取 modelUri 的末尾文件名(用于与已安装文件灵活匹配:不同 node-llama-cpp 版本可能生成不同前缀)。
4
4
  * 例:hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf → Qwen3-4B-Q4_K_M.gguf
5
+ * 例:hf_Qwen_Qwen3-4B-GGUF_Qwen3-4B-Q4_K_M.gguf → Qwen3-4B-Q4_K_M.gguf(文件名形式取最后一段 _ 之后)
5
6
  */
6
7
  export declare function modelUriBasename(modelUri: string): string;
7
8
  /**
@@ -9,12 +10,18 @@ export declare function modelUriBasename(modelUri: string): string;
9
10
  * 与 LocalModelsService.predictFilename 逻辑一致。
10
11
  */
11
12
  export declare function modelUriToFilename(modelUri: string): string;
13
+ /**
14
+ * 在缓存目录中解析出实际存在的模型文件路径。
15
+ * 先尝试精确文件名,若无则按「以 modelUri 的末尾文件名结尾」匹配(与「已安装的本地模型」逻辑一致)。
16
+ */
17
+ export declare function resolveModelPathInCache(modelIdOrUri: string, cacheDir?: string): string;
12
18
  /**
13
19
  * 检查指定模型(uri 或文件名)是否已存在于本地缓存目录。
20
+ * 支持精确文件名 或 以末尾 .gguf 文件名结尾的灵活匹配,与「已安装的本地模型」展示一致。
14
21
  */
15
22
  export declare function isModelFileInCache(modelIdOrUri: string, cacheDir?: string): boolean;
16
23
  /**
17
24
  * 将前端传入的模型标识(hf: URI 或已安装文件名)转为可传给 node-llama-cpp 的路径或 URI。
18
- * 若为纯文件名(如 hf_xxx.gguf),则返回缓存目录下的绝对路径。
25
+ * 若为纯文件名(如 hf_xxx.gguf),则返回缓存目录下的绝对路径;若实际磁盘文件名与配置不一致(如 node-llama-cpp 命名),则解析为真实路径。
19
26
  */
20
27
  export declare function toModelPathForStart(uriOrFilename: string, cacheDir?: string): string;
@@ -1,20 +1,30 @@
1
1
  /**
2
- * 本地模型路径解析与文件存在性检查(与 ~/.cache/llama 及 node-llama-cpp 命名一致)。
2
+ * 本地模型路径解析与文件存在性检查。
3
+ * 缓存目录:~/.openbot/.cached_models/,与 openbot 配置同目录便于管理。
4
+ * 与「已安装的本地模型」展示一致:支持精确文件名 或 以末尾 .gguf 文件名结尾的灵活匹配(兼容 node-llama-cpp 不同命名)。
3
5
  */
4
6
  import { join } from "node:path";
5
- import { existsSync } from "node:fs";
7
+ import { existsSync, readdirSync } from "node:fs";
6
8
  import { homedir } from "node:os";
7
- export const LOCAL_LLM_CACHE_DIR = join(homedir(), ".cache", "llama");
9
+ export const LOCAL_LLM_CACHE_DIR = join(homedir(), ".openbot", ".cached_models");
8
10
  /**
9
11
  * 取 modelUri 的末尾文件名(用于与已安装文件灵活匹配:不同 node-llama-cpp 版本可能生成不同前缀)。
10
12
  * 例:hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf → Qwen3-4B-Q4_K_M.gguf
13
+ * 例:hf_Qwen_Qwen3-4B-GGUF_Qwen3-4B-Q4_K_M.gguf → Qwen3-4B-Q4_K_M.gguf(文件名形式取最后一段 _ 之后)
11
14
  */
12
15
  export function modelUriBasename(modelUri) {
13
16
  const s = (modelUri || "").trim();
14
17
  if (!s)
15
18
  return "";
16
19
  const parts = s.replace(/\\/g, "/").split("/");
17
- return parts[parts.length - 1] || s;
20
+ const last = parts[parts.length - 1] || s;
21
+ // 仅对无 "/" 的文件名形式(如 hf_X_Y_Z.gguf)取最后 _ 之后一段,以匹配 node-llama-cpp 可能生成的短文件名
22
+ if (!s.includes("/") && last.includes("_") && last.endsWith(".gguf")) {
23
+ const fromUnderscore = last.slice(last.lastIndexOf("_") + 1);
24
+ if (fromUnderscore.endsWith(".gguf"))
25
+ return fromUnderscore;
26
+ }
27
+ return last;
18
28
  }
19
29
  /**
20
30
  * 将 modelUri(hf:owner/repo/file.gguf)或文件名转为缓存目录下的文件名。
@@ -33,17 +43,38 @@ export function modelUriToFilename(modelUri) {
33
43
  return last ?? s;
34
44
  }
35
45
  /**
36
- * 检查指定模型(uri 或文件名)是否已存在于本地缓存目录。
46
+ * 在缓存目录中解析出实际存在的模型文件路径。
47
+ * 先尝试精确文件名,若无则按「以 modelUri 的末尾文件名结尾」匹配(与「已安装的本地模型」逻辑一致)。
37
48
  */
38
- export function isModelFileInCache(modelIdOrUri, cacheDir = LOCAL_LLM_CACHE_DIR) {
49
+ export function resolveModelPathInCache(modelIdOrUri, cacheDir = LOCAL_LLM_CACHE_DIR) {
39
50
  const filename = modelUriToFilename(modelIdOrUri);
40
51
  if (!filename || !filename.endsWith(".gguf"))
41
- return false;
42
- return existsSync(join(cacheDir, filename));
52
+ return "";
53
+ const exactPath = join(cacheDir, filename);
54
+ if (existsSync(exactPath))
55
+ return exactPath;
56
+ const suffix = modelUriBasename(modelIdOrUri);
57
+ if (!suffix)
58
+ return "";
59
+ try {
60
+ const files = readdirSync(cacheDir);
61
+ const found = files.find((f) => f.endsWith(".gguf") && (f === suffix || f.endsWith(suffix)));
62
+ return found ? join(cacheDir, found) : "";
63
+ }
64
+ catch {
65
+ return "";
66
+ }
67
+ }
68
+ /**
69
+ * 检查指定模型(uri 或文件名)是否已存在于本地缓存目录。
70
+ * 支持精确文件名 或 以末尾 .gguf 文件名结尾的灵活匹配,与「已安装的本地模型」展示一致。
71
+ */
72
+ export function isModelFileInCache(modelIdOrUri, cacheDir = LOCAL_LLM_CACHE_DIR) {
73
+ return resolveModelPathInCache(modelIdOrUri, cacheDir) !== "";
43
74
  }
44
75
  /**
45
76
  * 将前端传入的模型标识(hf: URI 或已安装文件名)转为可传给 node-llama-cpp 的路径或 URI。
46
- * 若为纯文件名(如 hf_xxx.gguf),则返回缓存目录下的绝对路径。
77
+ * 若为纯文件名(如 hf_xxx.gguf),则返回缓存目录下的绝对路径;若实际磁盘文件名与配置不一致(如 node-llama-cpp 命名),则解析为真实路径。
47
78
  */
48
79
  export function toModelPathForStart(uriOrFilename, cacheDir = LOCAL_LLM_CACHE_DIR) {
49
80
  const s = (uriOrFilename || "").trim();
@@ -51,8 +82,9 @@ export function toModelPathForStart(uriOrFilename, cacheDir = LOCAL_LLM_CACHE_DI
51
82
  return "";
52
83
  if (s.startsWith("hf:"))
53
84
  return s;
85
+ const resolved = resolveModelPathInCache(s, cacheDir);
86
+ if (resolved)
87
+ return resolved;
54
88
  const filename = modelUriToFilename(s);
55
- if (!filename)
56
- return s;
57
- return join(cacheDir, filename);
89
+ return filename ? join(cacheDir, filename) : s;
58
90
  }