@next-open-ai/openclawx 0.8.36 → 0.8.48

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 (78) hide show
  1. package/README.md +60 -42
  2. package/apps/desktop/renderer/dist/assets/index-BHY1xIZQ.css +10 -0
  3. package/apps/desktop/renderer/dist/assets/index-DQxlVuBe.js +93 -0
  4. package/apps/desktop/renderer/dist/index.html +2 -2
  5. package/dist/cli/cli.js +29 -0
  6. package/dist/cli/extension-cmd.d.ts +15 -0
  7. package/dist/cli/extension-cmd.js +107 -0
  8. package/dist/core/agent/agent-dir.d.ts +6 -0
  9. package/dist/core/agent/agent-dir.js +8 -0
  10. package/dist/core/agent/agent-manager.d.ts +13 -0
  11. package/dist/core/agent/agent-manager.js +77 -7
  12. package/dist/core/agent/proxy/adapters/claude-code-adapter.d.ts +2 -0
  13. package/dist/core/agent/proxy/adapters/claude-code-adapter.js +186 -0
  14. package/dist/core/agent/proxy/adapters/local-adapter.js +3 -1
  15. package/dist/core/agent/proxy/adapters/opencode-adapter.js +65 -29
  16. package/dist/core/agent/proxy/adapters/opencode-local-runner.js +9 -0
  17. package/dist/core/agent/proxy/index.js +2 -0
  18. package/dist/core/agent/token-usage-log-extension.d.ts +14 -0
  19. package/dist/core/agent/token-usage-log-extension.js +61 -0
  20. package/dist/core/config/desktop-config.d.ts +24 -2
  21. package/dist/core/config/desktop-config.js +87 -10
  22. package/dist/core/config/provider-support-default.js +26 -0
  23. package/dist/core/extensions/index.d.ts +1 -0
  24. package/dist/core/extensions/index.js +1 -0
  25. package/dist/core/extensions/load.d.ts +11 -0
  26. package/dist/core/extensions/load.js +101 -0
  27. package/dist/core/local-llm-server/index.d.ts +32 -0
  28. package/dist/core/local-llm-server/index.js +126 -0
  29. package/dist/core/local-llm-server/llm-context.d.ts +60 -0
  30. package/dist/core/local-llm-server/llm-context.js +221 -0
  31. package/dist/core/local-llm-server/model-resolve.d.ts +20 -0
  32. package/dist/core/local-llm-server/model-resolve.js +58 -0
  33. package/dist/core/local-llm-server/server.d.ts +1 -0
  34. package/dist/core/local-llm-server/server.js +235 -0
  35. package/dist/core/mcp/adapter.d.ts +4 -2
  36. package/dist/core/mcp/adapter.js +10 -4
  37. package/dist/core/mcp/index.d.ts +2 -0
  38. package/dist/core/mcp/index.js +1 -0
  39. package/dist/core/mcp/operator.d.ts +2 -0
  40. package/dist/core/mcp/operator.js +1 -1
  41. package/dist/core/memory/local-embedding.d.ts +4 -3
  42. package/dist/core/memory/local-embedding.js +43 -3
  43. package/dist/core/tools/index.d.ts +1 -0
  44. package/dist/core/tools/index.js +1 -0
  45. package/dist/core/tools/truncate-result.d.ts +14 -0
  46. package/dist/core/tools/truncate-result.js +27 -0
  47. package/dist/core/tools/web-search/create-web-search-tool.d.ts +17 -0
  48. package/dist/core/tools/web-search/create-web-search-tool.js +87 -0
  49. package/dist/core/tools/web-search/index.d.ts +4 -0
  50. package/dist/core/tools/web-search/index.js +2 -0
  51. package/dist/core/tools/web-search/providers/brave.d.ts +2 -0
  52. package/dist/core/tools/web-search/providers/brave.js +87 -0
  53. package/dist/core/tools/web-search/providers/duck-duck-scrape.d.ts +2 -0
  54. package/dist/core/tools/web-search/providers/duck-duck-scrape.js +47 -0
  55. package/dist/core/tools/web-search/providers/index.d.ts +5 -0
  56. package/dist/core/tools/web-search/providers/index.js +13 -0
  57. package/dist/core/tools/web-search/types.d.ts +35 -0
  58. package/dist/core/tools/web-search/types.js +4 -0
  59. package/dist/gateway/methods/agent-chat.js +74 -42
  60. package/dist/gateway/methods/run-scheduled-task.js +2 -0
  61. package/dist/gateway/server.js +54 -1
  62. package/dist/server/agent-config/agent-config.controller.d.ts +1 -1
  63. package/dist/server/agent-config/agent-config.service.d.ts +17 -3
  64. package/dist/server/agent-config/agent-config.service.js +23 -0
  65. package/dist/server/config/config.controller.d.ts +84 -4
  66. package/dist/server/config/config.controller.js +135 -3
  67. package/dist/server/config/config.module.js +3 -2
  68. package/dist/server/config/config.service.d.ts +14 -0
  69. package/dist/server/config/local-models.service.d.ts +52 -0
  70. package/dist/server/config/local-models.service.js +211 -0
  71. package/package.json +3 -1
  72. package/presets/preset-agents.json +121 -91
  73. package/presets/recommended-local-models.json +42 -0
  74. package/presets/workspaces/finance-expert/skills/akshare-helper/SKILL.md +9 -0
  75. package/presets/workspaces/office-automation/skills/rpa-helper/SKILL.md +9 -0
  76. package/presets/workspaces/self-media-bot/skills/self-media-tools/SKILL.md +9 -0
  77. package/apps/desktop/renderer/dist/assets/index-BGHtXhm3.js +0 -89
  78. package/apps/desktop/renderer/dist/assets/index-CB2-m4ae.css +0 -10
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 从 ~/.openbot/plugins 目录加载通过 openbot extension install 安装的 npm 包,
3
+ * 将每个包的默认导出规范为 ExtensionFactory 并返回,供 AgentManager 注入到 DefaultResourceLoader.extensionFactories。
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ import { join } from "node:path";
8
+ import { getOpenbotPluginsDir } from "../agent/agent-dir.js";
9
+ let cachedFactories = null;
10
+ /**
11
+ * 从插件目录的 package.json 读取 dependencies(及 optionalDependencies)的包名列表。
12
+ * 仅返回在 node_modules 中实际存在的包名。
13
+ */
14
+ function getInstalledPluginNames(pluginsDir) {
15
+ const pkgPath = join(pluginsDir, "package.json");
16
+ if (!existsSync(pkgPath))
17
+ return [];
18
+ let pkg;
19
+ try {
20
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ const deps = {
26
+ ...pkg.dependencies,
27
+ ...pkg.optionalDependencies,
28
+ };
29
+ const names = Object.keys(deps || {});
30
+ return names.filter((name) => {
31
+ const dir = join(pluginsDir, "node_modules", name);
32
+ return existsSync(dir);
33
+ });
34
+ }
35
+ /**
36
+ * 将包默认导出规范为 ExtensionFactory:(pi) => void。
37
+ * 插件可导出 (pi) => void 或 () => (pi) => void(工厂),此处统一为 (pi) => void。
38
+ */
39
+ function toExtensionFactory(fn) {
40
+ if (typeof fn !== "function")
41
+ return null;
42
+ if (fn.length === 1)
43
+ return fn; // (pi) => void
44
+ if (fn.length === 0) {
45
+ const result = fn();
46
+ if (typeof result === "function")
47
+ return result; // () => (pi) => void
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * 加载单个插件包,返回 ExtensionFactory 或 null(失败时打日志并返回 null)。
53
+ * 使用 require(pkgName) 从 plugins 目录的 node_modules 解析,以便插件自身依赖正确解析。
54
+ */
55
+ function loadOnePlugin(pluginsDir, pkgName) {
56
+ const require = createRequire(join(pluginsDir, "package.json"));
57
+ let mod;
58
+ try {
59
+ mod = require(pkgName);
60
+ }
61
+ catch (err) {
62
+ console.warn(`[extensions] Failed to load plugin "${pkgName}":`, err);
63
+ return null;
64
+ }
65
+ const def = mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
66
+ const factory = toExtensionFactory(def);
67
+ if (!factory) {
68
+ console.warn(`[extensions] Plugin "${pkgName}" default export is not a function; skipped.`);
69
+ return null;
70
+ }
71
+ return factory;
72
+ }
73
+ /**
74
+ * 扫描 ~/.openbot/plugins,加载所有已安装的扩展包,返回 ExtensionFactory 数组。
75
+ * 进程内缓存结果;若需重载可调用 clearExtensionFactoriesCache()。
76
+ */
77
+ export function loadExtensionFactories() {
78
+ if (cachedFactories !== null)
79
+ return cachedFactories;
80
+ const pluginsDir = getOpenbotPluginsDir();
81
+ if (!existsSync(pluginsDir)) {
82
+ cachedFactories = [];
83
+ return cachedFactories;
84
+ }
85
+ const names = getInstalledPluginNames(pluginsDir);
86
+ const factories = [];
87
+ for (const name of names) {
88
+ const factory = loadOnePlugin(pluginsDir, name);
89
+ if (factory)
90
+ factories.push(factory);
91
+ }
92
+ cachedFactories = factories;
93
+ return factories;
94
+ }
95
+ /**
96
+ * 清除扩展 factory 缓存,下次 loadExtensionFactories() 时会重新扫描并加载。
97
+ * 用于安装/卸载扩展后希望不重启即生效的场景(若调用方在适当时机调用)。
98
+ */
99
+ export function clearExtensionFactoriesCache() {
100
+ cachedFactories = null;
101
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * local-llm-server 入口。
3
+ *
4
+ * 两种运行模式:
5
+ * 1. 子进程模式(--child):直接加载模型并启动 HTTP 服务,由主进程 fork 调用。
6
+ * 2. 主进程模式(默认导出):fork 子进程,管理其生命周期,提供 baseUrl 给调用方。
7
+ *
8
+ * 主进程通过 startLocalLlmServer() 启动,返回 { baseUrl, stop }。
9
+ * 子进程就绪后通过 IPC 发送 { type: "ready" } 通知主进程。
10
+ */
11
+ export interface LocalLlmServerOptions {
12
+ port?: number;
13
+ llmModelPath?: string;
14
+ embeddingModelPath?: string;
15
+ /** 上下文窗口 token 数,默认 32768(32K),需能容纳 system + tools + 对话 */
16
+ contextSize?: number;
17
+ /** 等待子进程就绪的超时毫秒数,默认 300000(5 分钟,冷启/大模型加载可能较慢) */
18
+ readyTimeoutMs?: number;
19
+ }
20
+ export interface LocalLlmServerHandle {
21
+ baseUrl: string;
22
+ stop: () => void;
23
+ }
24
+ /**
25
+ * 停止本地 LLM 子进程服务(若正在运行)。用于切换模型前先停止再启动。
26
+ */
27
+ export declare function stopLocalLlmServer(): void;
28
+ /**
29
+ * 启动本地 LLM 子进程服务。
30
+ * 已启动时直接返回已有 handle(单例)。需先 stop 再传新参数重启。
31
+ */
32
+ export declare function startLocalLlmServer(opts?: LocalLlmServerOptions): Promise<LocalLlmServerHandle>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * local-llm-server 入口。
3
+ *
4
+ * 两种运行模式:
5
+ * 1. 子进程模式(--child):直接加载模型并启动 HTTP 服务,由主进程 fork 调用。
6
+ * 2. 主进程模式(默认导出):fork 子进程,管理其生命周期,提供 baseUrl 给调用方。
7
+ *
8
+ * 主进程通过 startLocalLlmServer() 启动,返回 { baseUrl, stop }。
9
+ * 子进程就绪后通过 IPC 发送 { type: "ready" } 通知主进程。
10
+ */
11
+ import { fileURLToPath } from "node:url";
12
+ // ─── 子进程模式 ───────────────────────────────────────────────────────────────
13
+ async function runChildProcess() {
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;
18
+ const { initModels } = await import("./llm-context.js");
19
+ const { createOpenAICompatServer } = await import("./server.js");
20
+ try {
21
+ await initModels({ llmModelPath, embeddingModelPath: embModelPath, contextSize: contextSize ?? 32768 });
22
+ await createOpenAICompatServer(port);
23
+ // 通知主进程已就绪
24
+ if (process.send) {
25
+ process.send({ type: "ready", port });
26
+ }
27
+ }
28
+ catch (e) {
29
+ console.error("[local-llm] 子进程启动失败:", e);
30
+ if (process.send) {
31
+ process.send({ type: "error", message: String(e) });
32
+ }
33
+ process.exit(1);
34
+ }
35
+ }
36
+ let serverHandle = null;
37
+ /**
38
+ * 停止本地 LLM 子进程服务(若正在运行)。用于切换模型前先停止再启动。
39
+ */
40
+ export function stopLocalLlmServer() {
41
+ if (serverHandle) {
42
+ serverHandle.stop();
43
+ serverHandle = null;
44
+ }
45
+ }
46
+ /**
47
+ * 启动本地 LLM 子进程服务。
48
+ * 已启动时直接返回已有 handle(单例)。需先 stop 再传新参数重启。
49
+ */
50
+ export async function startLocalLlmServer(opts = {}) {
51
+ if (serverHandle)
52
+ return serverHandle;
53
+ const { fork } = await import("node:child_process");
54
+ const port = opts.port ?? 11435;
55
+ const readyTimeoutMs = opts.readyTimeoutMs ?? 300_000;
56
+ const env = {
57
+ ...process.env,
58
+ LOCAL_LLM_PORT: String(port),
59
+ LOCAL_LLM_CHILD: "1",
60
+ };
61
+ if (opts.llmModelPath)
62
+ env.LOCAL_LLM_MODEL = opts.llmModelPath;
63
+ if (opts.embeddingModelPath)
64
+ env.LOCAL_EMB_MODEL = opts.embeddingModelPath;
65
+ if (opts.contextSize != null)
66
+ env.LOCAL_LLM_CONTEXT_SIZE = String(opts.contextSize);
67
+ const childPath = fileURLToPath(import.meta.url);
68
+ const child = fork(childPath, ["--child"], {
69
+ env,
70
+ stdio: ["ignore", "inherit", "inherit", "ipc"],
71
+ execArgv: [],
72
+ });
73
+ await new Promise((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ child.kill();
76
+ reject(new Error(`[local-llm] 子进程启动超时(${readyTimeoutMs}ms)`));
77
+ }, readyTimeoutMs);
78
+ child.on("message", (msg) => {
79
+ if (msg?.type === "ready") {
80
+ clearTimeout(timer);
81
+ resolve();
82
+ }
83
+ else if (msg?.type === "error") {
84
+ clearTimeout(timer);
85
+ reject(new Error(`[local-llm] 子进程错误: ${msg.message}`));
86
+ }
87
+ });
88
+ child.on("exit", (code) => {
89
+ clearTimeout(timer);
90
+ if (code !== 0)
91
+ reject(new Error(`[local-llm] 子进程意外退出,code=${code}`));
92
+ });
93
+ child.on("error", (e) => {
94
+ clearTimeout(timer);
95
+ reject(e);
96
+ });
97
+ });
98
+ // 主进程退出时清理子进程
99
+ const cleanup = () => { try {
100
+ child.kill();
101
+ }
102
+ catch { /* ignore */ } };
103
+ process.on("exit", cleanup);
104
+ process.on("SIGINT", cleanup);
105
+ process.on("SIGTERM", cleanup);
106
+ serverHandle = {
107
+ baseUrl: `http://127.0.0.1:${port}/v1`,
108
+ stop: () => {
109
+ serverHandle = null;
110
+ try {
111
+ child.kill();
112
+ }
113
+ catch { /* ignore */ }
114
+ },
115
+ };
116
+ console.log(`[local-llm] 本地服务就绪: ${serverHandle.baseUrl}`);
117
+ return serverHandle;
118
+ }
119
+ // ─── 入口判断 ─────────────────────────────────────────────────────────────────
120
+ // 子进程模式:被 fork 时带 --child 参数或设置了 LOCAL_LLM_CHILD 环境变量
121
+ if (process.argv.includes("--child") || process.env.LOCAL_LLM_CHILD === "1") {
122
+ runChildProcess().catch((e) => {
123
+ console.error("[local-llm] 致命错误:", e);
124
+ process.exit(1);
125
+ });
126
+ }
@@ -0,0 +1,60 @@
1
+ export interface LlmContextOptions {
2
+ /** LLM 推理模型路径或 hf: URI */
3
+ llmModelPath: string;
4
+ /** Embedding 模型路径或 hf: URI */
5
+ embeddingModelPath: string;
6
+ /** GPU layers,-1 表示全部卸载到 GPU(Metal),0 表示纯 CPU */
7
+ gpuLayers?: number;
8
+ /** 上下文窗口大小,默认 32768(32K) */
9
+ contextSize?: number;
10
+ }
11
+ export interface ChatMessage {
12
+ role: "system" | "user" | "assistant" | "tool";
13
+ content: string | null;
14
+ /** tool_calls(assistant 发起工具调用时) */
15
+ tool_calls?: ToolCall[];
16
+ /** tool_call_id(role=tool 时,对应哪个 tool_call) */
17
+ tool_call_id?: string;
18
+ /** tool 消息的函数名 */
19
+ name?: string;
20
+ }
21
+ export interface ToolDefinition {
22
+ type: "function";
23
+ function: {
24
+ name: string;
25
+ description?: string;
26
+ parameters?: Record<string, unknown>;
27
+ };
28
+ }
29
+ export interface ToolCall {
30
+ id: string;
31
+ type: "function";
32
+ function: {
33
+ name: string;
34
+ arguments: string;
35
+ };
36
+ }
37
+ export interface ChatCompletionChunk {
38
+ content?: string;
39
+ tool_calls?: ToolCall[];
40
+ finish_reason?: "stop" | "tool_calls" | "length";
41
+ }
42
+ export declare function initModels(opts: LlmContextOptions): Promise<void>;
43
+ /**
44
+ * 流式 chat completion。
45
+ * onChunk 每次收到新 token 时调用;结束后返回完整 finish_reason。
46
+ */
47
+ export declare function chatCompletionStream(messages: ChatMessage[], tools: ToolDefinition[], onChunk: (chunk: ChatCompletionChunk) => void, signal?: AbortSignal): Promise<void>;
48
+ /**
49
+ * 非流式 chat completion(内部复用流式实现)。
50
+ */
51
+ export declare function chatCompletion(messages: ChatMessage[], tools: ToolDefinition[], signal?: AbortSignal): Promise<{
52
+ content: string;
53
+ tool_calls?: ToolCall[];
54
+ finish_reason: string;
55
+ }>;
56
+ /**
57
+ * 文本 embedding,返回 L2 归一化向量。
58
+ */
59
+ export declare function getEmbedding(text: string): Promise<number[]>;
60
+ export declare function isReady(): boolean;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * node-llama-cpp 模型实例管理。
3
+ * 同时持有一个 LLM chat 模型和一个 embedding 模型,各自独立上下文。
4
+ * 推理和 embedding 请求串行处理(同一模型不支持并发),两个模型之间可并发。
5
+ */
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ let llama = null;
9
+ let llmModel = null;
10
+ let embeddingModel = null;
11
+ let embeddingCtx = null;
12
+ /** 上下文窗口大小,initModels 时设置,用于 createContext;默认 32K 以容纳较长 system + tools */
13
+ let storedContextSize = 32768;
14
+ /** 串行锁:同一模型同一时间只处理一个推理请求 */
15
+ let llmQueue = Promise.resolve();
16
+ async function getLlamaInstance(gpuLayers) {
17
+ if (llama)
18
+ return llama;
19
+ const { getLlama, LlamaLogLevel } = await import("node-llama-cpp");
20
+ llama = await getLlama({
21
+ logLevel: LlamaLogLevel.warn,
22
+ ...(gpuLayers !== undefined ? { gpu: gpuLayers === 0 ? false : "auto" } : {}),
23
+ });
24
+ return llama;
25
+ }
26
+ export async function initModels(opts) {
27
+ storedContextSize = opts.contextSize ?? 32768;
28
+ const { resolveModelFile } = await import("node-llama-cpp");
29
+ const cacheDir = join(homedir(), ".cache", "llama");
30
+ 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] 模型加载完成");
39
+ }
40
+ /** 将 API 可能传来的 content(string | array 如 [{ type: "text", text: "..." }])规范为 string,避免 node-llama-cpp LlamaText.fromJSON 收到对象抛 "Unknown value type: [object Object]" */
41
+ function contentToString(content) {
42
+ if (content == null)
43
+ return "";
44
+ if (typeof content === "string")
45
+ return content;
46
+ if (!Array.isArray(content))
47
+ return String(content);
48
+ return content
49
+ .filter((part) => part != null && typeof part === "object")
50
+ .map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : ""))
51
+ .join("");
52
+ }
53
+ /**
54
+ * 将 ChatMessage[] 转换为 node-llama-cpp 的 LlamaChatMessage[]。
55
+ * tool_calls 序列化为 assistant content;tool 结果作为 user content 回传。
56
+ * 入参 content 可能是 OpenAI 多段格式(content: [{ type: "text", text: "..." }]),必须规范为 string。
57
+ */
58
+ function toLocalMessages(messages) {
59
+ return messages.map((m) => {
60
+ const rawContent = m.content;
61
+ const content = contentToString(rawContent);
62
+ if (m.role === "tool") {
63
+ return { role: "user", content: `[Tool result for ${m.name ?? m.tool_call_id ?? "tool"}]: ${content}` };
64
+ }
65
+ if (m.role === "assistant" && m.tool_calls?.length) {
66
+ const calls = JSON.stringify(m.tool_calls);
67
+ return { role: "assistant", content: content + `\n[tool_calls]: ${calls}` };
68
+ }
69
+ return { role: m.role, content };
70
+ });
71
+ }
72
+ /**
73
+ * 将 tools 定义转换为 grammar 约束描述,拼入 system prompt。
74
+ * node-llama-cpp v3 通过 LlamaGrammar 支持 JSON schema 约束输出,
75
+ * 这里用 prompt 方式描述工具,让模型以 JSON 格式输出 tool_calls。
76
+ */
77
+ function buildToolSystemPrompt(tools) {
78
+ if (!tools.length)
79
+ return "";
80
+ const descs = tools.map((t) => {
81
+ const fn = t.function;
82
+ return `- ${fn.name}: ${fn.description ?? ""}\n parameters: ${JSON.stringify(fn.parameters ?? {})}`;
83
+ }).join("\n");
84
+ return `\n\nYou have access to the following tools. When you need to call a tool, respond ONLY with a JSON object in this exact format (no other text):\n{"tool_calls":[{"id":"call_<random>","type":"function","function":{"name":"<tool_name>","arguments":"<json_string>"}}]}\n\nAvailable tools:\n${descs}`;
85
+ }
86
+ /** 尝试从模型输出中解析 tool_calls JSON */
87
+ function parseToolCalls(text) {
88
+ const trimmed = text.trim();
89
+ // 匹配 {"tool_calls":[...]} 格式
90
+ const match = trimmed.match(/\{[\s\S]*"tool_calls"[\s\S]*\}/);
91
+ if (!match)
92
+ return null;
93
+ try {
94
+ const parsed = JSON.parse(match[0]);
95
+ if (Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
96
+ return parsed.tool_calls;
97
+ }
98
+ }
99
+ catch {
100
+ // 不是合法 JSON,当普通文本处理
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * 流式 chat completion。
106
+ * onChunk 每次收到新 token 时调用;结束后返回完整 finish_reason。
107
+ */
108
+ export async function chatCompletionStream(messages, tools, onChunk, signal) {
109
+ if (!llmModel)
110
+ throw new Error("[local-llm] LLM 模型未初始化");
111
+ const { LlamaChatSession } = await import("node-llama-cpp");
112
+ // 串行排队
113
+ const run = async () => {
114
+ const ctx = await llmModel.createContext({ contextSize: storedContextSize });
115
+ // 注入历史消息(除最后一条 user 消息)
116
+ const localMsgs = toLocalMessages(messages);
117
+ let lastUser = -1;
118
+ for (let i = localMsgs.length - 1; i >= 0; i--) {
119
+ if (localMsgs[i].role === "user") {
120
+ lastUser = i;
121
+ break;
122
+ }
123
+ }
124
+ const history = lastUser > 0 ? localMsgs.slice(0, lastUser) : [];
125
+ const userPrompt = lastUser >= 0 ? localMsgs[lastUser].content : "";
126
+ // 找 system prompt,拼入 tool 描述(system 的 content 也可能是 array,需规范为 string)
127
+ const systemMsg = messages.find((m) => m.role === "system");
128
+ const toolSystemPrompt = buildToolSystemPrompt(tools);
129
+ const systemContent = contentToString(systemMsg?.content) + toolSystemPrompt;
130
+ // 创建带 systemPrompt 的 session,重建历史
131
+ const session = new LlamaChatSession({
132
+ contextSequence: ctx.getSequence(),
133
+ systemPrompt: systemContent || undefined,
134
+ });
135
+ for (const msg of history) {
136
+ if (msg.role === "user") {
137
+ await session.prompt(msg.content, { onTextChunk: () => { } });
138
+ }
139
+ }
140
+ let fullText = "";
141
+ let prevSentLength = 0;
142
+ let lastSent = ""; // 连续相同 delta 只发一次,缓解回复缓慢时「每个字显示两遍」
143
+ try {
144
+ await session.prompt(userPrompt, {
145
+ onTextChunk: (token) => {
146
+ if (signal?.aborted)
147
+ return;
148
+ const s = typeof token === "string" ? token : (token != null ? String(token) : "");
149
+ if (!s)
150
+ return;
151
+ // node-llama-cpp 在 detokenize(tokens, false, tokenTrail) 时可能返回带上下文的片段(含重复前缀),
152
+ // 与 DeepSeek 等仅返回增量不同。只向下游发送增量,避免出现「你好你好!!我是我是...」式重复。
153
+ if (s.startsWith(fullText)) {
154
+ fullText = s;
155
+ }
156
+ else {
157
+ fullText += s;
158
+ }
159
+ const toSend = fullText.slice(prevSentLength);
160
+ prevSentLength = fullText.length;
161
+ if (toSend && toSend !== lastSent) {
162
+ lastSent = toSend;
163
+ onChunk({ content: toSend });
164
+ }
165
+ },
166
+ signal,
167
+ });
168
+ }
169
+ catch (e) {
170
+ // node-llama-cpp 在解析模型输出(如 segment/tool_call)时可能对 LlamaText.fromJSON 传入对象导致 "Unknown value type: [object Object]"
171
+ const msg = e instanceof Error ? e.message : String(e);
172
+ const stack = e instanceof Error ? e.stack : undefined;
173
+ console.error("[local-llm] chatCompletionStream session.prompt error:", msg);
174
+ if (stack)
175
+ console.error("[local-llm] stack:", stack);
176
+ throw e;
177
+ }
178
+ // 检查是否是 tool_calls 输出
179
+ const toolCalls = parseToolCalls(fullText);
180
+ if (toolCalls) {
181
+ onChunk({ tool_calls: toolCalls, finish_reason: "tool_calls" });
182
+ }
183
+ else {
184
+ onChunk({ finish_reason: "stop" });
185
+ }
186
+ await ctx.dispose();
187
+ };
188
+ llmQueue = llmQueue.then(run, run);
189
+ await llmQueue;
190
+ }
191
+ /**
192
+ * 非流式 chat completion(内部复用流式实现)。
193
+ */
194
+ export async function chatCompletion(messages, tools, signal) {
195
+ let content = "";
196
+ let toolCalls;
197
+ let finishReason = "stop";
198
+ await chatCompletionStream(messages, tools, (chunk) => {
199
+ if (chunk.content)
200
+ content += chunk.content;
201
+ if (chunk.tool_calls)
202
+ toolCalls = chunk.tool_calls;
203
+ if (chunk.finish_reason)
204
+ finishReason = chunk.finish_reason;
205
+ }, signal);
206
+ return { content, tool_calls: toolCalls, finish_reason: finishReason };
207
+ }
208
+ /**
209
+ * 文本 embedding,返回 L2 归一化向量。
210
+ */
211
+ export async function getEmbedding(text) {
212
+ if (!embeddingCtx)
213
+ throw new Error("[local-llm] Embedding 模型未初始化");
214
+ const result = await embeddingCtx.getEmbeddingFor(text);
215
+ const vec = Array.from(result.vector);
216
+ const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
217
+ return vec.map((v) => v / norm);
218
+ }
219
+ export function isReady() {
220
+ return llmModel !== null && embeddingCtx !== null;
221
+ }
@@ -0,0 +1,20 @@
1
+ export declare const LOCAL_LLM_CACHE_DIR: string;
2
+ /**
3
+ * 取 modelUri 的末尾文件名(用于与已安装文件灵活匹配:不同 node-llama-cpp 版本可能生成不同前缀)。
4
+ * 例:hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf → Qwen3-4B-Q4_K_M.gguf
5
+ */
6
+ export declare function modelUriBasename(modelUri: string): string;
7
+ /**
8
+ * 将 modelUri(hf:owner/repo/file.gguf)或文件名转为缓存目录下的文件名。
9
+ * 与 LocalModelsService.predictFilename 逻辑一致。
10
+ */
11
+ export declare function modelUriToFilename(modelUri: string): string;
12
+ /**
13
+ * 检查指定模型(uri 或文件名)是否已存在于本地缓存目录。
14
+ */
15
+ export declare function isModelFileInCache(modelIdOrUri: string, cacheDir?: string): boolean;
16
+ /**
17
+ * 将前端传入的模型标识(hf: URI 或已安装文件名)转为可传给 node-llama-cpp 的路径或 URI。
18
+ * 若为纯文件名(如 hf_xxx.gguf),则返回缓存目录下的绝对路径。
19
+ */
20
+ export declare function toModelPathForStart(uriOrFilename: string, cacheDir?: string): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 本地模型路径解析与文件存在性检查(与 ~/.cache/llama 及 node-llama-cpp 命名一致)。
3
+ */
4
+ import { join } from "node:path";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ export const LOCAL_LLM_CACHE_DIR = join(homedir(), ".cache", "llama");
8
+ /**
9
+ * 取 modelUri 的末尾文件名(用于与已安装文件灵活匹配:不同 node-llama-cpp 版本可能生成不同前缀)。
10
+ * 例:hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf → Qwen3-4B-Q4_K_M.gguf
11
+ */
12
+ export function modelUriBasename(modelUri) {
13
+ const s = (modelUri || "").trim();
14
+ if (!s)
15
+ return "";
16
+ const parts = s.replace(/\\/g, "/").split("/");
17
+ return parts[parts.length - 1] || s;
18
+ }
19
+ /**
20
+ * 将 modelUri(hf:owner/repo/file.gguf)或文件名转为缓存目录下的文件名。
21
+ * 与 LocalModelsService.predictFilename 逻辑一致。
22
+ */
23
+ export function modelUriToFilename(modelUri) {
24
+ const s = (modelUri || "").trim();
25
+ if (!s)
26
+ return "";
27
+ if (s.startsWith("hf:")) {
28
+ const parts = s.slice(3).split("/");
29
+ return "hf_" + parts.slice(0, -1).join("_") + "_" + parts[parts.length - 1];
30
+ }
31
+ // 已是文件名或路径,只取 basename
32
+ const last = s.replace(/\\/g, "/").split("/").pop();
33
+ return last ?? s;
34
+ }
35
+ /**
36
+ * 检查指定模型(uri 或文件名)是否已存在于本地缓存目录。
37
+ */
38
+ export function isModelFileInCache(modelIdOrUri, cacheDir = LOCAL_LLM_CACHE_DIR) {
39
+ const filename = modelUriToFilename(modelIdOrUri);
40
+ if (!filename || !filename.endsWith(".gguf"))
41
+ return false;
42
+ return existsSync(join(cacheDir, filename));
43
+ }
44
+ /**
45
+ * 将前端传入的模型标识(hf: URI 或已安装文件名)转为可传给 node-llama-cpp 的路径或 URI。
46
+ * 若为纯文件名(如 hf_xxx.gguf),则返回缓存目录下的绝对路径。
47
+ */
48
+ export function toModelPathForStart(uriOrFilename, cacheDir = LOCAL_LLM_CACHE_DIR) {
49
+ const s = (uriOrFilename || "").trim();
50
+ if (!s)
51
+ return "";
52
+ if (s.startsWith("hf:"))
53
+ return s;
54
+ const filename = modelUriToFilename(s);
55
+ if (!filename)
56
+ return s;
57
+ return join(cacheDir, filename);
58
+ }
@@ -0,0 +1 @@
1
+ export declare function createOpenAICompatServer(port: number): Promise<void>;