comfyui-mcp 0.23.4 → 0.24.0
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.
- package/README.md +48 -9
- package/dist/index.js +55 -5
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/agent-backend.js +18 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/backend-readiness.js +20 -0
- package/dist/orchestrator/backend-readiness.js.map +1 -1
- package/dist/orchestrator/index.js +44 -5
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +668 -0
- package/dist/orchestrator/ollama-backend.js.map +1 -0
- package/dist/services/agent-setup.js +122 -0
- package/dist/services/agent-setup.js.map +1 -0
- package/dist/services/ui-bridge.js +58 -0
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/tools/catalog.js +77 -0
- package/dist/tools/catalog.js.map +1 -0
- package/dist/tools/compact.js +174 -0
- package/dist/tools/compact.js.map +1 -0
- package/dist/tools/index.js +74 -47
- package/dist/tools/index.js.map +1 -1
- package/dist/transport/cli.js +45 -0
- package/dist/transport/cli.js.map +1 -1
- package/package.json +5 -2
- package/scripts/arena-bestof.mjs +88 -0
- package/scripts/arena-graphic.mjs +119 -0
- package/scripts/llm-arena.mjs +553 -0
- package/scripts/panel-smoke.mjs +217 -0
- package/scripts/test-local-llm.mjs +150 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
// Ollama local-LLM adapter for the panel orchestrator (issue #97's panel phase).
|
|
2
|
+
//
|
|
3
|
+
// Unlike the Claude/Codex/Gemini adapters, the "provider" here is a plain HTTP
|
|
4
|
+
// daemon with OpenAI-style tool calling and NO agent harness — so this backend
|
|
5
|
+
// owns the whole agentic loop itself: it streams /api/chat NDJSON, dispatches
|
|
6
|
+
// tool calls, and feeds results back until the model produces a final answer.
|
|
7
|
+
//
|
|
8
|
+
// Local models can't survive the full ~200-schema comfyui surface plus ~40
|
|
9
|
+
// panel_* schemas, so the model sees exactly SIX tools (the "tool router"
|
|
10
|
+
// pattern from issue #97):
|
|
11
|
+
// list_tools / describe_tool / call_tool — passthrough to a headless
|
|
12
|
+
// comfyui MCP subprocess spawned in COMPACT mode (3 meta-tools built in)
|
|
13
|
+
// panel_list_tools / panel_describe_tool / panel_call_tool — synthesized
|
|
14
|
+
// here over the orchestrator's loopback panel HTTP MCP (live-graph tools)
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { logger } from "../utils/logger.js";
|
|
17
|
+
import { OLLAMA_CAPABILITIES } from "./agent-backend.js";
|
|
18
|
+
/** Convert the neutral in-memory history to the OpenAI wire shape: tool-call
|
|
19
|
+
* arguments must be JSON STRINGS, every call needs an id, and tool results
|
|
20
|
+
* pair by tool_call_id (tool_name is an Ollama-ism the strict endpoints
|
|
21
|
+
* reject). */
|
|
22
|
+
function toOpenAiMessages(messages) {
|
|
23
|
+
return messages.map((m) => {
|
|
24
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
25
|
+
return {
|
|
26
|
+
role: "assistant",
|
|
27
|
+
content: m.content || null,
|
|
28
|
+
tool_calls: m.tool_calls.map((tc, i) => ({
|
|
29
|
+
id: tc.id ?? `call_${i}`,
|
|
30
|
+
type: "function",
|
|
31
|
+
function: {
|
|
32
|
+
name: tc.function.name,
|
|
33
|
+
arguments: typeof tc.function.arguments === "string"
|
|
34
|
+
? tc.function.arguments
|
|
35
|
+
: JSON.stringify(tc.function.arguments ?? {}),
|
|
36
|
+
},
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (m.role === "tool") {
|
|
41
|
+
return { role: "tool", tool_call_id: m.tool_call_id ?? "call_0", content: m.content };
|
|
42
|
+
}
|
|
43
|
+
return { role: m.role, content: m.content };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// The LLM Arena's best performer (scripts/llm-arena.mjs): 9/10, cleanest runs.
|
|
47
|
+
const DEFAULT_MODEL = "gemma4:e4b";
|
|
48
|
+
const MAX_TOOL_ROUNDS = 32;
|
|
49
|
+
/**
|
|
50
|
+
* The Ollama system prompt REPLACES the frontier panel prompt: that one is
|
|
51
|
+
* thousands of tokens and instructs the agent to call dozens of tools BY NAME
|
|
52
|
+
* (panel_get_graph, list_packs, …) that don't exist on this backend's 6-tool
|
|
53
|
+
* router — a small model obeys it, hits "unknown tool", and gives up. This one
|
|
54
|
+
* is short, router-shaped, and (deliberately, for local models) does NOT carry
|
|
55
|
+
* the NSFW consent-gate flow — only the absolute hard limits.
|
|
56
|
+
*/
|
|
57
|
+
const OLLAMA_SYSTEM_PROMPT = [
|
|
58
|
+
"You are the ComfyUI agent in a sidebar panel, driving the user's live ComfyUI graph and server. Answer in normal Markdown.",
|
|
59
|
+
"",
|
|
60
|
+
"You have exactly six tools:",
|
|
61
|
+
'- list_tools / describe_tool / call_tool — the headless ComfyUI server (~200 capabilities: generate images/video/audio, models, custom nodes, queue, diagnostics). Flow: list_tools {"search": ...} → describe_tool {"name": ...} → call_tool {"name": ..., "args": {...}}.',
|
|
62
|
+
"- panel_list_tools / panel_describe_tool / panel_call_tool — the user's LIVE canvas (read the graph, add/wire nodes, set widgets, run, screenshots, show media). Same flow.",
|
|
63
|
+
"",
|
|
64
|
+
"Rules:",
|
|
65
|
+
"- Catalog entries are tool NAMES, not data. Finish every task by actually running tools; never invent results.",
|
|
66
|
+
"- Describe a tool before its first call so you use the right parameters. If a call errors, read the error — it includes the expected schema — fix the args and retry.",
|
|
67
|
+
"- To see or show any generated image/video, run the panel_show_media tool via panel_call_tool.",
|
|
68
|
+
"- Workflows with API nodes cost the user PAID credits; local-GPU workflows are free. Ask before anything that might spend credits.",
|
|
69
|
+
].join("\n");
|
|
70
|
+
function msgOf(err) {
|
|
71
|
+
return err instanceof Error ? err.message : String(err);
|
|
72
|
+
}
|
|
73
|
+
function textOf(result) {
|
|
74
|
+
return (result.content ?? [])
|
|
75
|
+
.filter((c) => c.type === "text")
|
|
76
|
+
.map((c) => c.text ?? "")
|
|
77
|
+
.join("\n");
|
|
78
|
+
}
|
|
79
|
+
function firstSentence(text, maxLen = 160) {
|
|
80
|
+
const line = (text.split(/(?<=\.)\s+/, 1)[0] ?? text).replace(/\s+/g, " ").trim();
|
|
81
|
+
return line.length <= maxLen ? line : `${line.slice(0, maxLen - 1).trimEnd()}…`;
|
|
82
|
+
}
|
|
83
|
+
/** Does this id look like a model this backend can run? PanelAgent
|
|
84
|
+
* unconditionally passes the panel's Claude model as opts.model — this guard
|
|
85
|
+
* keeps the configured model in charge unless the panel explicitly picked one
|
|
86
|
+
* of ours. Ollama tags carry a ":" (qwen3:4b); hosted OpenAI-compatible slugs
|
|
87
|
+
* carry a "/" vendor prefix (deepseek/deepseek-v3.2, anthropic/claude-…).
|
|
88
|
+
* Mirrors gemini-backend's isGeminiModel. */
|
|
89
|
+
export function isOllamaModel(id) {
|
|
90
|
+
return (id.includes(":") || id.includes("/")) && !/^claude|^gpt|^gemini/i.test(id);
|
|
91
|
+
}
|
|
92
|
+
export class OllamaBackend {
|
|
93
|
+
id = "ollama";
|
|
94
|
+
capabilities = OLLAMA_CAPABILITIES;
|
|
95
|
+
deps;
|
|
96
|
+
host;
|
|
97
|
+
model;
|
|
98
|
+
disposed = false;
|
|
99
|
+
prepared = false;
|
|
100
|
+
/** In-flight turn abort — interrupt() aborts the current fetch/loop. */
|
|
101
|
+
turnAbort = null;
|
|
102
|
+
comfy = null;
|
|
103
|
+
panel = null;
|
|
104
|
+
/** comfyui compact meta-tool defs (from tools/list) — handed to the model verbatim. */
|
|
105
|
+
comfyTools = [];
|
|
106
|
+
/** panel_* tool list (full defs stay HERE; the model gets 3 meta-tools). */
|
|
107
|
+
panelTools = [];
|
|
108
|
+
/** Conversation history for the live session (Ollama is stateless per request). */
|
|
109
|
+
history = [];
|
|
110
|
+
sessionId = null;
|
|
111
|
+
/** Wire dialect (see OllamaBackendDeps.api). */
|
|
112
|
+
api;
|
|
113
|
+
apiKey;
|
|
114
|
+
constructor(deps = {}) {
|
|
115
|
+
this.deps = deps;
|
|
116
|
+
this.api = deps.api ?? "ollama";
|
|
117
|
+
this.apiKey = deps.apiKey;
|
|
118
|
+
this.host = (deps.host ?? process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434").replace(/\/$/, "");
|
|
119
|
+
this.model = deps.model ?? DEFAULT_MODEL;
|
|
120
|
+
}
|
|
121
|
+
authHeaders() {
|
|
122
|
+
return this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {};
|
|
123
|
+
}
|
|
124
|
+
async prepare() {
|
|
125
|
+
if (this.disposed)
|
|
126
|
+
throw new Error("ollama backend is closed.");
|
|
127
|
+
if (this.prepared)
|
|
128
|
+
return;
|
|
129
|
+
let version = "?";
|
|
130
|
+
try {
|
|
131
|
+
if (this.api === "openai") {
|
|
132
|
+
const res = await fetch(`${this.host}/models`, {
|
|
133
|
+
headers: this.authHeaders(),
|
|
134
|
+
signal: AbortSignal.timeout(5000),
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok)
|
|
137
|
+
throw new Error(`http ${res.status}`);
|
|
138
|
+
version = "openai-compatible";
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const res = await fetch(`${this.host}/api/version`, { signal: AbortSignal.timeout(3000) });
|
|
142
|
+
if (!res.ok)
|
|
143
|
+
throw new Error(`http ${res.status}`);
|
|
144
|
+
version = (await res.json()).version ?? "?";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
throw new Error(this.api === "openai"
|
|
149
|
+
? `The OpenAI-compatible endpoint at ${this.host} is not reachable or rejected the key (${msgOf(err)}).`
|
|
150
|
+
: `Ollama is not reachable at ${this.host} (${msgOf(err)}). Start it with \`ollama serve\` (install: https://ollama.com/download) and pull a tool-calling model, e.g. \`ollama pull ${this.model}\`.`);
|
|
151
|
+
}
|
|
152
|
+
await this.connectTools();
|
|
153
|
+
this.prepared = true;
|
|
154
|
+
logger.info(`[ollama-backend] ready (${this.api === "openai" ? `openai-compatible @ ${this.host}` : `ollama ${version}`}, model ${this.model}, ${this.comfyTools.length} comfyui meta-tools, ${this.panelTools.length} panel tools behind the router)`);
|
|
155
|
+
}
|
|
156
|
+
async connectTools() {
|
|
157
|
+
if (this.deps.connectToolClients) {
|
|
158
|
+
const { comfyui, panel } = await this.deps.connectToolClients();
|
|
159
|
+
this.comfy = comfyui ?? null;
|
|
160
|
+
this.panel = panel ?? null;
|
|
161
|
+
}
|
|
162
|
+
else if (this.deps.mcpServers) {
|
|
163
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
164
|
+
for (const [name, spec] of Object.entries(this.deps.mcpServers)) {
|
|
165
|
+
try {
|
|
166
|
+
const client = new Client({ name: `ollama-backend-${name}`, version: "0.0.0" });
|
|
167
|
+
if (spec.transport === "stdio") {
|
|
168
|
+
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
169
|
+
await client.connect(new StdioClientTransport({
|
|
170
|
+
command: spec.command,
|
|
171
|
+
args: spec.args ?? [],
|
|
172
|
+
// Compact mode: the subprocess itself exposes the 3 meta-tools.
|
|
173
|
+
env: { ...process.env, ...spec.env, COMFYUI_MCP_TOOL_MODE: "compact" },
|
|
174
|
+
}));
|
|
175
|
+
this.comfy = client;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
179
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(spec.url)));
|
|
180
|
+
this.panel = client;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
logger.warn(`[ollama-backend] could not connect MCP server '${name}': ${msgOf(err)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (this.comfy)
|
|
189
|
+
this.comfyTools = (await this.comfy.listTools()).tools;
|
|
190
|
+
if (this.panel)
|
|
191
|
+
this.panelTools = (await this.panel.listTools()).tools;
|
|
192
|
+
}
|
|
193
|
+
/** The six OpenAI-style tool defs the model sees. */
|
|
194
|
+
buildModelTools() {
|
|
195
|
+
const defs = [];
|
|
196
|
+
for (const t of this.comfyTools) {
|
|
197
|
+
defs.push({
|
|
198
|
+
type: "function",
|
|
199
|
+
function: { name: t.name, description: t.description ?? "", parameters: t.inputSchema ?? { type: "object", properties: {} } },
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (this.panel && this.panelTools.length) {
|
|
203
|
+
defs.push({
|
|
204
|
+
type: "function",
|
|
205
|
+
function: {
|
|
206
|
+
name: "panel_list_tools",
|
|
207
|
+
description: "List the live-canvas panel tools (the user's open ComfyUI graph): names + one-line summaries. Use panel_describe_tool then panel_call_tool to run one.",
|
|
208
|
+
parameters: {
|
|
209
|
+
type: "object",
|
|
210
|
+
properties: { search: { type: "string", description: "Case-insensitive substring filter." } },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
}, {
|
|
214
|
+
type: "function",
|
|
215
|
+
function: {
|
|
216
|
+
name: "panel_describe_tool",
|
|
217
|
+
description: "Full description and JSON Schema for one panel tool.",
|
|
218
|
+
parameters: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: { name: { type: "string", description: "Exact panel tool name." } },
|
|
221
|
+
required: ["name"],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
}, {
|
|
225
|
+
type: "function",
|
|
226
|
+
function: {
|
|
227
|
+
name: "panel_call_tool",
|
|
228
|
+
description: "Run a panel tool by name with args matching its panel_describe_tool schema.",
|
|
229
|
+
parameters: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {
|
|
232
|
+
name: { type: "string", description: "Exact panel tool name." },
|
|
233
|
+
args: { description: "The tool's parameters as an object (JSON-encoded string also accepted)." },
|
|
234
|
+
},
|
|
235
|
+
required: ["name"],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return defs;
|
|
241
|
+
}
|
|
242
|
+
/** Dispatch one model tool call; returns display text (never throws). */
|
|
243
|
+
async dispatch(name, rawArgs) {
|
|
244
|
+
let args = {};
|
|
245
|
+
if (typeof rawArgs === "string") {
|
|
246
|
+
try {
|
|
247
|
+
args = rawArgs.trim() ? JSON.parse(rawArgs) : {};
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return { text: `arguments were not valid JSON: ${rawArgs.slice(0, 200)}`, isError: true };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else if (rawArgs && typeof rawArgs === "object") {
|
|
254
|
+
args = rawArgs;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
if (this.comfyTools.some((t) => t.name === name)) {
|
|
258
|
+
if (!this.comfy)
|
|
259
|
+
return { text: "comfyui tools are unavailable in this session.", isError: true };
|
|
260
|
+
const res = await this.comfy.callTool({ name, arguments: args });
|
|
261
|
+
return { text: textOf(res), isError: !!res.isError };
|
|
262
|
+
}
|
|
263
|
+
if (name === "panel_list_tools") {
|
|
264
|
+
const search = typeof args.search === "string" ? args.search.toLowerCase() : "";
|
|
265
|
+
const matching = search
|
|
266
|
+
? this.panelTools.filter((t) => t.name.toLowerCase().includes(search) || (t.description ?? "").toLowerCase().includes(search))
|
|
267
|
+
: this.panelTools;
|
|
268
|
+
if (!matching.length)
|
|
269
|
+
return { text: `No panel tools matched '${search}'. Call panel_list_tools with no filter to see all ${this.panelTools.length}.`, isError: false };
|
|
270
|
+
const lines = matching.map((t) => `- ${t.name}: ${firstSentence(t.description ?? "")}`);
|
|
271
|
+
return {
|
|
272
|
+
text: `Live-canvas panel tools — ${matching.length} of ${this.panelTools.length}. Next: panel_describe_tool {"name": ...} then panel_call_tool.\n${lines.join("\n")}`,
|
|
273
|
+
isError: false,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (name === "panel_describe_tool") {
|
|
277
|
+
const wanted = typeof args.name === "string" ? args.name : "";
|
|
278
|
+
const tool = this.panelTools.find((t) => t.name === wanted);
|
|
279
|
+
if (!tool) {
|
|
280
|
+
const close = this.panelTools.filter((t) => t.name.includes(wanted)).slice(0, 5).map((t) => t.name);
|
|
281
|
+
return { text: `Unknown panel tool '${wanted}'.${close.length ? ` Did you mean: ${close.join(", ")}?` : ""} Use panel_list_tools.`, isError: true };
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
text: `# ${tool.name}\n\n${tool.description ?? ""}\n\nParameters (JSON Schema):\n${JSON.stringify(tool.inputSchema ?? {}, null, 1)}\n\nRun it with: panel_call_tool {"name": "${tool.name}", "args": {...}}`,
|
|
285
|
+
isError: false,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (name === "panel_call_tool") {
|
|
289
|
+
if (!this.panel)
|
|
290
|
+
return { text: "panel tools are unavailable in this session.", isError: true };
|
|
291
|
+
const wanted = typeof args.name === "string" ? args.name : typeof args.tool_name === "string" ? args.tool_name : "";
|
|
292
|
+
if (!this.panelTools.some((t) => t.name === wanted)) {
|
|
293
|
+
return { text: `Unknown panel tool '${wanted}'. Use panel_list_tools.`, isError: true };
|
|
294
|
+
}
|
|
295
|
+
let inner = args.args ?? args.arguments ?? {};
|
|
296
|
+
if (typeof inner === "string") {
|
|
297
|
+
try {
|
|
298
|
+
inner = inner.trim() ? JSON.parse(inner) : {};
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return { text: `args was not valid JSON: ${inner.slice(0, 200)}`, isError: true };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (inner === null || typeof inner !== "object" || Array.isArray(inner)) {
|
|
305
|
+
return { text: `args must be a JSON object. See panel_describe_tool {"name": "${wanted}"}.`, isError: true };
|
|
306
|
+
}
|
|
307
|
+
const res = await this.panel.callTool({ name: wanted, arguments: inner });
|
|
308
|
+
if (res.isError) {
|
|
309
|
+
logger.warn(`[ollama-backend] panel tool '${wanted}' returned isError: ${textOf(res).slice(0, 300)}`);
|
|
310
|
+
}
|
|
311
|
+
return { text: textOf(res), isError: !!res.isError };
|
|
312
|
+
}
|
|
313
|
+
// FORGIVING DIRECT DISPATCH — small models routinely call an inner tool
|
|
314
|
+
// by its bare name instead of going through the router. If the name is a
|
|
315
|
+
// real panel tool, run it on the panel client; anything else is handed to
|
|
316
|
+
// the compact server's call_tool, whose unknown-name error carries
|
|
317
|
+
// close-match suggestions the model can recover from.
|
|
318
|
+
if (this.panel && this.panelTools.some((t) => t.name === name)) {
|
|
319
|
+
const res = await this.panel.callTool({ name, arguments: args });
|
|
320
|
+
return { text: textOf(res), isError: !!res.isError };
|
|
321
|
+
}
|
|
322
|
+
if (this.comfy && this.comfyTools.some((t) => t.name === "call_tool")) {
|
|
323
|
+
const res = await this.comfy.callTool({ name: "call_tool", arguments: { name, args } });
|
|
324
|
+
return { text: textOf(res), isError: !!res.isError };
|
|
325
|
+
}
|
|
326
|
+
const known = [...this.comfyTools.map((t) => t.name), "panel_list_tools", "panel_describe_tool", "panel_call_tool"];
|
|
327
|
+
return { text: `Unknown tool '${name}'. Available: ${known.join(", ")}.`, isError: true };
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
logger.warn(`[ollama-backend] tool '${name}' dispatch failed: ${msgOf(err)}`);
|
|
331
|
+
return { text: `Tool '${name}' failed: ${msgOf(err)}`, isError: true };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** One /api/chat request (streaming). YIELDS delta events as chunks arrive and
|
|
335
|
+
* RETURNS the accumulated assistant message + usage (read via iterator.next()
|
|
336
|
+
* in runTurn so deltas stream through run() live). */
|
|
337
|
+
async *chatStream(messages, tools, signal, onActivity) {
|
|
338
|
+
// Keep the turn watchdog armed while the request is pending: a cold model
|
|
339
|
+
// load can sit 30s+ before the first byte — the provider is alive (the
|
|
340
|
+
// HTTP request is in flight), it's just loading weights into VRAM.
|
|
341
|
+
const keepalive = onActivity ? setInterval(onActivity, 5000) : null;
|
|
342
|
+
let res;
|
|
343
|
+
try {
|
|
344
|
+
res =
|
|
345
|
+
this.api === "openai"
|
|
346
|
+
? await fetch(`${this.host}/chat/completions`, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers: { "content-type": "application/json", ...this.authHeaders() },
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
model: this.model,
|
|
351
|
+
messages: toOpenAiMessages(messages),
|
|
352
|
+
tools,
|
|
353
|
+
tool_choice: "auto",
|
|
354
|
+
stream: true,
|
|
355
|
+
stream_options: { include_usage: true },
|
|
356
|
+
// Cap the output reservation: without it some models default to
|
|
357
|
+
// 65k, which both invites runaways and 402s on low prepaid
|
|
358
|
+
// balances (the request reserves credits for max_tokens).
|
|
359
|
+
max_tokens: Number(process.env.COMFYUI_MCP_OLLAMA_MAX_TOKENS) || 8192,
|
|
360
|
+
}),
|
|
361
|
+
signal,
|
|
362
|
+
})
|
|
363
|
+
: await fetch(`${this.host}/api/chat`, {
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: { "content-type": "application/json" },
|
|
366
|
+
body: JSON.stringify({
|
|
367
|
+
model: this.model,
|
|
368
|
+
messages,
|
|
369
|
+
tools,
|
|
370
|
+
stream: true,
|
|
371
|
+
options: { num_ctx: this.deps.numCtx ?? 16384 },
|
|
372
|
+
}),
|
|
373
|
+
signal,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
if (keepalive)
|
|
378
|
+
clearInterval(keepalive);
|
|
379
|
+
}
|
|
380
|
+
if (!res.ok || !res.body) {
|
|
381
|
+
throw new Error(`${this.api === "openai" ? `${this.host}/chat/completions` : "ollama /api/chat"} http ${res.status}: ${(await res.text().catch(() => "")).slice(0, 300)}`);
|
|
382
|
+
}
|
|
383
|
+
if (this.api === "openai") {
|
|
384
|
+
return yield* this.readOpenAiSse(res.body, onActivity);
|
|
385
|
+
}
|
|
386
|
+
let content = "";
|
|
387
|
+
const toolCalls = [];
|
|
388
|
+
let usage;
|
|
389
|
+
let streamOpen = false;
|
|
390
|
+
const streamId = randomUUID();
|
|
391
|
+
let buffer = "";
|
|
392
|
+
const reader = res.body.getReader();
|
|
393
|
+
const decoder = new TextDecoder();
|
|
394
|
+
for (;;) {
|
|
395
|
+
const { done, value } = await reader.read();
|
|
396
|
+
if (done)
|
|
397
|
+
break;
|
|
398
|
+
onActivity?.();
|
|
399
|
+
buffer += decoder.decode(value, { stream: true });
|
|
400
|
+
let nl;
|
|
401
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
402
|
+
const line = buffer.slice(0, nl).trim();
|
|
403
|
+
buffer = buffer.slice(nl + 1);
|
|
404
|
+
if (!line)
|
|
405
|
+
continue;
|
|
406
|
+
let chunk;
|
|
407
|
+
try {
|
|
408
|
+
chunk = JSON.parse(line);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (chunk.error)
|
|
414
|
+
throw new Error(`ollama: ${chunk.error}`);
|
|
415
|
+
const delta = chunk.message?.content ?? "";
|
|
416
|
+
if (delta) {
|
|
417
|
+
if (!streamOpen) {
|
|
418
|
+
streamOpen = true;
|
|
419
|
+
yield { type: "stream_start", id: streamId };
|
|
420
|
+
}
|
|
421
|
+
content += delta;
|
|
422
|
+
yield { type: "assistant_delta", text: delta };
|
|
423
|
+
}
|
|
424
|
+
if (chunk.message?.thinking) {
|
|
425
|
+
// thinking deltas need an open bubble too (think-window rendering)
|
|
426
|
+
if (!streamOpen) {
|
|
427
|
+
streamOpen = true;
|
|
428
|
+
yield { type: "stream_start", id: streamId };
|
|
429
|
+
}
|
|
430
|
+
yield { type: "assistant_delta", text: chunk.message.thinking, thinking: true };
|
|
431
|
+
}
|
|
432
|
+
if (chunk.message?.tool_calls?.length)
|
|
433
|
+
toolCalls.push(...chunk.message.tool_calls);
|
|
434
|
+
if (chunk.done) {
|
|
435
|
+
usage = {
|
|
436
|
+
input_tokens: chunk.prompt_eval_count ?? 0,
|
|
437
|
+
output_tokens: chunk.eval_count ?? 0,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (streamOpen)
|
|
443
|
+
yield { type: "stream_end" };
|
|
444
|
+
// streamId is returned only when a bubble was opened, so the assistant
|
|
445
|
+
// COMMIT can carry the same id — that reconciliation is what lets the
|
|
446
|
+
// panel replace the plain-text live bubble with the markdown-rendered
|
|
447
|
+
// message. A missing id left the raw text on screen (no markdown).
|
|
448
|
+
return { content, toolCalls, usage, streamId: streamOpen ? streamId : null };
|
|
449
|
+
}
|
|
450
|
+
/** OpenAI-compatible SSE reader: `data:` lines with choices[0].delta.
|
|
451
|
+
* Tool calls stream as FRAGMENTS keyed by index (name once, arguments as
|
|
452
|
+
* string chunks) — accumulate them into whole calls. */
|
|
453
|
+
async *readOpenAiSse(body, onActivity) {
|
|
454
|
+
let content = "";
|
|
455
|
+
let usage;
|
|
456
|
+
let streamOpen = false;
|
|
457
|
+
const streamId = randomUUID();
|
|
458
|
+
const partial = new Map();
|
|
459
|
+
let buffer = "";
|
|
460
|
+
const reader = body.getReader();
|
|
461
|
+
const decoder = new TextDecoder();
|
|
462
|
+
for (;;) {
|
|
463
|
+
const { done, value } = await reader.read();
|
|
464
|
+
if (done)
|
|
465
|
+
break;
|
|
466
|
+
onActivity?.();
|
|
467
|
+
buffer += decoder.decode(value, { stream: true });
|
|
468
|
+
let nl;
|
|
469
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
470
|
+
const line = buffer.slice(0, nl).trim();
|
|
471
|
+
buffer = buffer.slice(nl + 1);
|
|
472
|
+
if (!line.startsWith("data:"))
|
|
473
|
+
continue;
|
|
474
|
+
const payload = line.slice(5).trim();
|
|
475
|
+
if (!payload || payload === "[DONE]")
|
|
476
|
+
continue;
|
|
477
|
+
let chunk;
|
|
478
|
+
try {
|
|
479
|
+
chunk = JSON.parse(payload);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (chunk.error?.message)
|
|
485
|
+
throw new Error(`endpoint: ${chunk.error.message}`);
|
|
486
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
487
|
+
if (delta?.content) {
|
|
488
|
+
if (!streamOpen) {
|
|
489
|
+
streamOpen = true;
|
|
490
|
+
yield { type: "stream_start", id: streamId };
|
|
491
|
+
}
|
|
492
|
+
content += delta.content;
|
|
493
|
+
yield { type: "assistant_delta", text: delta.content };
|
|
494
|
+
}
|
|
495
|
+
if (delta?.reasoning) {
|
|
496
|
+
if (!streamOpen) {
|
|
497
|
+
streamOpen = true;
|
|
498
|
+
yield { type: "stream_start", id: streamId };
|
|
499
|
+
}
|
|
500
|
+
yield { type: "assistant_delta", text: delta.reasoning, thinking: true };
|
|
501
|
+
}
|
|
502
|
+
for (const tc of delta?.tool_calls ?? []) {
|
|
503
|
+
const idx = tc.index ?? 0;
|
|
504
|
+
const slot = partial.get(idx) ?? { id: undefined, name: "", args: "" };
|
|
505
|
+
if (tc.id)
|
|
506
|
+
slot.id = tc.id;
|
|
507
|
+
if (tc.function?.name)
|
|
508
|
+
slot.name = tc.function.name;
|
|
509
|
+
if (tc.function?.arguments)
|
|
510
|
+
slot.args += tc.function.arguments;
|
|
511
|
+
partial.set(idx, slot);
|
|
512
|
+
}
|
|
513
|
+
if (chunk.usage) {
|
|
514
|
+
usage = {
|
|
515
|
+
input_tokens: chunk.usage.prompt_tokens ?? 0,
|
|
516
|
+
output_tokens: chunk.usage.completion_tokens ?? 0,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (streamOpen)
|
|
522
|
+
yield { type: "stream_end" };
|
|
523
|
+
const toolCalls = [...partial.entries()]
|
|
524
|
+
.sort((a, b) => a[0] - b[0])
|
|
525
|
+
.map(([i, s]) => ({ id: s.id ?? `call_${i}`, function: { name: s.name, arguments: s.args || "{}" } }));
|
|
526
|
+
return { content, toolCalls, usage, streamId: streamOpen ? streamId : null };
|
|
527
|
+
}
|
|
528
|
+
async *run(opts) {
|
|
529
|
+
await this.prepare();
|
|
530
|
+
if (opts.model && isOllamaModel(opts.model))
|
|
531
|
+
this.model = opts.model;
|
|
532
|
+
// Ollama is stateless — "session" is our in-memory history. A resume id is
|
|
533
|
+
// honored in name (the panel replays the transcript as context anyway).
|
|
534
|
+
const fresh = !this.sessionId || (opts.resume && opts.resume !== this.sessionId);
|
|
535
|
+
this.sessionId = opts.resume ?? this.sessionId ?? `ollama-${randomUUID()}`;
|
|
536
|
+
if (fresh) {
|
|
537
|
+
// deps.systemAppend (the frontier panel prompt) is intentionally NOT
|
|
538
|
+
// used — see OLLAMA_SYSTEM_PROMPT.
|
|
539
|
+
this.history = [{ role: "system", content: OLLAMA_SYSTEM_PROMPT }];
|
|
540
|
+
}
|
|
541
|
+
yield { type: "session", sessionId: this.sessionId, model: this.model };
|
|
542
|
+
for await (const turn of opts.channel) {
|
|
543
|
+
yield* this.runTurn(turn, opts);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async *runTurn(turn, opts) {
|
|
547
|
+
const abort = new AbortController();
|
|
548
|
+
this.turnAbort = abort;
|
|
549
|
+
const tools = this.buildModelTools();
|
|
550
|
+
this.history.push({ role: "user", content: turn.text });
|
|
551
|
+
let resultEmitted = false;
|
|
552
|
+
try {
|
|
553
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
554
|
+
// Drain the chat stream manually: yield each delta event as it arrives,
|
|
555
|
+
// and capture the generator's RETURN value (the accumulated message).
|
|
556
|
+
const stream = this.chatStream(this.history, tools, abort.signal, opts.onActivity);
|
|
557
|
+
let content = "";
|
|
558
|
+
let toolCalls = [];
|
|
559
|
+
let usage;
|
|
560
|
+
let streamId = null;
|
|
561
|
+
for (;;) {
|
|
562
|
+
const r = await stream.next();
|
|
563
|
+
if (r.done) {
|
|
564
|
+
({ content, toolCalls, usage, streamId } = r.value);
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
yield r.value;
|
|
568
|
+
}
|
|
569
|
+
if (!toolCalls.length) {
|
|
570
|
+
yield { type: "assistant", text: content, id: streamId ?? undefined, usage };
|
|
571
|
+
yield { type: "result", ok: true, usage };
|
|
572
|
+
resultEmitted = true;
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
this.history.push({ role: "assistant", content, tool_calls: toolCalls });
|
|
576
|
+
for (const [i, tc] of toolCalls.entries()) {
|
|
577
|
+
if (abort.signal.aborted)
|
|
578
|
+
throw new Error("interrupted");
|
|
579
|
+
const name = tc.function?.name ?? "?";
|
|
580
|
+
yield { type: "tool_call", name, phase: "start", detail: tc.function?.arguments };
|
|
581
|
+
const { text, isError } = await this.dispatch(name, tc.function?.arguments ?? {});
|
|
582
|
+
opts.onActivity?.();
|
|
583
|
+
yield { type: "tool_call", name, phase: "end", detail: { isError } };
|
|
584
|
+
this.history.push({
|
|
585
|
+
role: "tool",
|
|
586
|
+
tool_name: name,
|
|
587
|
+
tool_call_id: tc.id ?? `call_${i}`,
|
|
588
|
+
content: text.slice(0, 16000),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Round budget exhausted — commit what we have so the turn gate advances.
|
|
593
|
+
yield {
|
|
594
|
+
type: "assistant",
|
|
595
|
+
text: "(stopped: too many tool rounds in one turn — ask me to continue)",
|
|
596
|
+
};
|
|
597
|
+
yield { type: "result", ok: false, subtype: "max_tool_rounds" };
|
|
598
|
+
resultEmitted = true;
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
const interrupted = abort.signal.aborted;
|
|
602
|
+
if (!interrupted) {
|
|
603
|
+
// Surface the failure IN the chat too — an error event alone leaves the
|
|
604
|
+
// panel silent (the turn just ends), which reads as a wedge.
|
|
605
|
+
logger.warn(`[ollama-backend] turn failed: ${msgOf(err)}`);
|
|
606
|
+
yield { type: "error", message: `ollama backend: ${msgOf(err)}` };
|
|
607
|
+
yield {
|
|
608
|
+
type: "assistant",
|
|
609
|
+
text: `⚠️ The model request failed: ${msgOf(err).slice(0, 400)}`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
if (!resultEmitted) {
|
|
613
|
+
yield { type: "result", ok: false, subtype: interrupted ? "interrupted" : "error" };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
if (this.turnAbort === abort)
|
|
618
|
+
this.turnAbort = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async interrupt() {
|
|
622
|
+
this.turnAbort?.abort();
|
|
623
|
+
}
|
|
624
|
+
async setModel(model) {
|
|
625
|
+
// Ollama picks the model per request — a live switch is just bookkeeping.
|
|
626
|
+
if (isOllamaModel(model))
|
|
627
|
+
this.model = model;
|
|
628
|
+
}
|
|
629
|
+
async listModels() {
|
|
630
|
+
try {
|
|
631
|
+
if (this.api === "openai") {
|
|
632
|
+
const res = await fetch(`${this.host}/models`, {
|
|
633
|
+
headers: this.authHeaders(),
|
|
634
|
+
signal: AbortSignal.timeout(8000),
|
|
635
|
+
});
|
|
636
|
+
if (!res.ok)
|
|
637
|
+
return [{ id: this.model, label: this.model }];
|
|
638
|
+
const data = (await res.json());
|
|
639
|
+
const ids = (data.data ?? []).map((m) => m.id).filter((n) => !!n);
|
|
640
|
+
// Hosted catalogs can be huge (OpenRouter: 300+). Surface the configured
|
|
641
|
+
// model first, then a bounded slice — the panel picker isn't a browser.
|
|
642
|
+
const rest = ids.filter((id) => id !== this.model).slice(0, 40);
|
|
643
|
+
return [this.model, ...rest].map((id) => ({ id, label: id }));
|
|
644
|
+
}
|
|
645
|
+
const res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
646
|
+
if (!res.ok)
|
|
647
|
+
return [];
|
|
648
|
+
const data = (await res.json());
|
|
649
|
+
return (data.models ?? [])
|
|
650
|
+
.map((m) => m.name)
|
|
651
|
+
.filter((n) => !!n)
|
|
652
|
+
.map((id) => ({ id, label: id }));
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return this.api === "openai" ? [{ id: this.model, label: this.model }] : [];
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async close() {
|
|
659
|
+
this.disposed = true;
|
|
660
|
+
this.turnAbort?.abort();
|
|
661
|
+
await this.comfy?.close().catch(() => { });
|
|
662
|
+
await this.panel?.close().catch(() => { });
|
|
663
|
+
this.comfy = null;
|
|
664
|
+
this.panel = null;
|
|
665
|
+
this.prepared = false;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
//# sourceMappingURL=ollama-backend.js.map
|