@webmcp-auto-ui/agent 2.5.26 → 2.5.28
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/package.json +10 -2
- package/src/autoui-server.ts +80 -65
- package/src/index.ts +25 -6
- package/src/loop.ts +52 -33
- package/src/prompts/claude-prompt-builder.ts +81 -0
- package/src/prompts/gemma4-prompt-builder.ts +205 -0
- package/src/prompts/index.ts +55 -0
- package/src/prompts/mistral-prompt-builder.ts +90 -0
- package/src/prompts/qwen-prompt-builder.ts +90 -0
- package/src/prompts/tool-call-parsers.ts +322 -0
- package/src/prompts/tool-refs.ts +196 -0
- package/src/providers/factory.ts +34 -3
- package/src/providers/hawk-models.ts +22 -0
- package/src/providers/hawk.ts +181 -0
- package/src/providers/transformers-models.ts +143 -0
- package/src/providers/transformers-serialize.ts +81 -0
- package/src/providers/transformers.ts +329 -0
- package/src/providers/transformers.worker.ts +640 -0
- package/src/providers/wasm.ts +132 -332
- package/src/recipes/_generated.ts +306 -0
- package/src/recipes/hackathon-assemblee-nationale.md +111 -0
- package/src/recipes/notebook-playbook.md +193 -0
- package/src/server/hawkProxy.ts +54 -0
- package/src/server/index.ts +2 -0
- package/src/tool-layers.ts +7 -403
- package/src/trace-observer.ts +669 -0
- package/src/types.ts +17 -7
- package/src/util/opfs-cache.ts +364 -0
- package/src/util/storage-inventory.ts +195 -0
- package/tests/gemma-prompt.test.ts +472 -0
- package/tests/loop.test.ts +3 -3
- package/tests/transformers-serialize.test.ts +103 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Unified tool-call parser for in-browser providers.
|
|
2
|
+
// Each family of models emits tool calls in a different syntax:
|
|
3
|
+
// - Gemma 4 native → <|tool_call>call:name{args}<tool_call|>
|
|
4
|
+
// - Qwen 3/3.5 (ChatML) → <tool_call>{"name": ..., "arguments": {...}}</tool_call>
|
|
5
|
+
// - Mistral/Ministral → [TOOL_CALLS][{"name": ..., "arguments": {...}}]
|
|
6
|
+
//
|
|
7
|
+
// Each parser returns a list of ContentBlocks — tool_use blocks for detected
|
|
8
|
+
// calls, plus a single text block with the remaining prose (tool-call tags
|
|
9
|
+
// stripped, thinking tags stripped for Qwen).
|
|
10
|
+
|
|
11
|
+
import type { ContentBlock } from '../types.js';
|
|
12
|
+
|
|
13
|
+
export type ToolCallFormat = 'gemma-native' | 'qwen-json' | 'mistral-toolcalls';
|
|
14
|
+
|
|
15
|
+
export interface ParseResult {
|
|
16
|
+
content: ContentBlock[];
|
|
17
|
+
foundToolCall: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseToolCalls(text: string, format: ToolCallFormat): ParseResult {
|
|
21
|
+
switch (format) {
|
|
22
|
+
case 'gemma-native': return parseGemmaNative(text);
|
|
23
|
+
case 'qwen-json': return parseQwenJson(text);
|
|
24
|
+
case 'mistral-toolcalls': return parseMistralToolCalls(text);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Gemma 4 native — single source of truth (previously also in wasm.ts).
|
|
30
|
+
// Exported so that WasmProvider can import and reuse instead of duplicating.
|
|
31
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract a brace-balanced `{...}` block starting at `text[startIdx]`.
|
|
35
|
+
* Ignores `{` and `}` that appear inside `<|"|>...<|"|>` string delimiters
|
|
36
|
+
* or inside standard JSON `"..."` strings.
|
|
37
|
+
* Returns the full block including outer braces, or null if unbalanced.
|
|
38
|
+
*/
|
|
39
|
+
export function extractArgsBlock(text: string, startIdx: number): string | null {
|
|
40
|
+
if (text[startIdx] !== '{') return null;
|
|
41
|
+
const DELIM = '<|"|>';
|
|
42
|
+
let depth = 0;
|
|
43
|
+
let i = startIdx;
|
|
44
|
+
while (i < text.length) {
|
|
45
|
+
if (text.startsWith(DELIM, i)) {
|
|
46
|
+
i += DELIM.length;
|
|
47
|
+
const end = text.indexOf(DELIM, i);
|
|
48
|
+
if (end === -1) return null;
|
|
49
|
+
i = end + DELIM.length;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (text[i] === '"') {
|
|
53
|
+
i++;
|
|
54
|
+
while (i < text.length && text[i] !== '"') {
|
|
55
|
+
if (text[i] === '\\' && i + 1 < text.length) { i += 2; continue; }
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
i++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (text[i] === '{') depth++;
|
|
62
|
+
else if (text[i] === '}') {
|
|
63
|
+
depth--;
|
|
64
|
+
if (depth === 0) return text.slice(startIdx, i + 1);
|
|
65
|
+
}
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Skip "noise" chars that Gemma sometimes hallucinates between the end of a
|
|
73
|
+
* balanced args block and the `<tool_call|>` closing tag. Observed in prod:
|
|
74
|
+
* excess `}` braces, trailing commas, whitespace. Without this tolerance the
|
|
75
|
+
* strict scanner rejects the whole tool call silently (no tool_use produced),
|
|
76
|
+
* which breaks widget rendering.
|
|
77
|
+
*/
|
|
78
|
+
function skipNoise(text: string, pos: number): number {
|
|
79
|
+
while (pos < text.length) {
|
|
80
|
+
const c = text[pos];
|
|
81
|
+
if (c === '}' || c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === ',') {
|
|
82
|
+
pos++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return pos;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Scan `fullText` for Gemma native tool calls and return the list of
|
|
92
|
+
* `{ name, argsBlock }` pairs found. Tolerates hallucinated noise
|
|
93
|
+
* (excess `}`, whitespace, trailing commas) between the balanced args
|
|
94
|
+
* block and the `<tool_call|>` closing tag.
|
|
95
|
+
*/
|
|
96
|
+
export function extractGemmaToolCalls(fullText: string): Array<{ name: string; argsBlock: string }> {
|
|
97
|
+
const START_TAG = '<|tool_call>call:';
|
|
98
|
+
const END_TAG = '<tool_call|>';
|
|
99
|
+
const out: Array<{ name: string; argsBlock: string }> = [];
|
|
100
|
+
let scanIdx = 0;
|
|
101
|
+
while (true) {
|
|
102
|
+
const startIdx = fullText.indexOf(START_TAG, scanIdx);
|
|
103
|
+
if (startIdx === -1) break;
|
|
104
|
+
const nameStart = startIdx + START_TAG.length;
|
|
105
|
+
const braceIdx = fullText.indexOf('{', nameStart);
|
|
106
|
+
if (braceIdx === -1) break;
|
|
107
|
+
const name = fullText.slice(nameStart, braceIdx);
|
|
108
|
+
if (!/^\w+$/.test(name)) { scanIdx = nameStart; continue; }
|
|
109
|
+
const argsBlock = extractArgsBlock(fullText, braceIdx);
|
|
110
|
+
if (!argsBlock) break;
|
|
111
|
+
const afterArgsRaw = braceIdx + argsBlock.length;
|
|
112
|
+
const afterArgs = skipNoise(fullText, afterArgsRaw);
|
|
113
|
+
if (!fullText.startsWith(END_TAG, afterArgs)) { scanIdx = afterArgsRaw; continue; }
|
|
114
|
+
out.push({ name, argsBlock });
|
|
115
|
+
scanIdx = afterArgs + END_TAG.length;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse Gemma native tool call args by normalizing to strict JSON.
|
|
122
|
+
* Handles both `<|"|>...<|"|>` (Gemma native) and `"..."` (JSON-style, emitted
|
|
123
|
+
* when the model copies JS-syntax examples from recipe bodies). Raw newlines
|
|
124
|
+
* inside JSON strings are escaped. Unquoted keys are quoted.
|
|
125
|
+
*/
|
|
126
|
+
export function parseGemmaArgs(raw: string): Record<string, unknown> {
|
|
127
|
+
const DELIM = '<|"|>';
|
|
128
|
+
let out = '';
|
|
129
|
+
let i = 0;
|
|
130
|
+
while (i < raw.length) {
|
|
131
|
+
if (raw.startsWith(DELIM, i)) {
|
|
132
|
+
i += DELIM.length;
|
|
133
|
+
const end = raw.indexOf(DELIM, i);
|
|
134
|
+
if (end === -1) return {};
|
|
135
|
+
// Decode standard backslash escapes inside <|"|>…<|"|> strings so that
|
|
136
|
+
// a recipe-copied `\n` becomes a real newline, not the two-char
|
|
137
|
+
// sequence `\n` that would then be re-escaped to `\\n` by
|
|
138
|
+
// JSON.stringify and reach the sandbox as literal text. Other
|
|
139
|
+
// backslash-x sequences are preserved verbatim (single backslash kept).
|
|
140
|
+
const body = raw.slice(i, end);
|
|
141
|
+
let decoded = '';
|
|
142
|
+
for (let k = 0; k < body.length; k++) {
|
|
143
|
+
const ch = body[k];
|
|
144
|
+
if (ch === '\\' && k + 1 < body.length) {
|
|
145
|
+
const nxt = body[k + 1];
|
|
146
|
+
if (nxt === 'n') { decoded += '\n'; k++; continue; }
|
|
147
|
+
if (nxt === 't') { decoded += '\t'; k++; continue; }
|
|
148
|
+
if (nxt === 'r') { decoded += '\r'; k++; continue; }
|
|
149
|
+
if (nxt === '"') { decoded += '"'; k++; continue; }
|
|
150
|
+
if (nxt === '\\') { decoded += '\\'; k++; continue; }
|
|
151
|
+
// Unknown escape — keep the backslash verbatim
|
|
152
|
+
decoded += ch;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
decoded += ch;
|
|
156
|
+
}
|
|
157
|
+
out += JSON.stringify(decoded);
|
|
158
|
+
i = end + DELIM.length;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const c = raw[i];
|
|
162
|
+
if (c === '"') {
|
|
163
|
+
let content = '';
|
|
164
|
+
i++;
|
|
165
|
+
while (i < raw.length && raw[i] !== '"') {
|
|
166
|
+
const ch = raw[i];
|
|
167
|
+
if (ch === '\\' && i + 1 < raw.length) { content += ch + raw[i + 1]; i += 2; continue; }
|
|
168
|
+
if (ch === '\n') content += '\\n';
|
|
169
|
+
else if (ch === '\r') content += '\\r';
|
|
170
|
+
else if (ch === '\t') content += '\\t';
|
|
171
|
+
else content += ch;
|
|
172
|
+
i++;
|
|
173
|
+
}
|
|
174
|
+
if (i >= raw.length) return {};
|
|
175
|
+
out += '"' + content + '"';
|
|
176
|
+
i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (c === '{' || c === ',') {
|
|
180
|
+
out += c;
|
|
181
|
+
i++;
|
|
182
|
+
while (i < raw.length && /\s/.test(raw[i])) { out += raw[i++]; }
|
|
183
|
+
const keyStart = i;
|
|
184
|
+
while (i < raw.length && /[a-zA-Z0-9_$]/.test(raw[i])) i++;
|
|
185
|
+
if (i > keyStart) {
|
|
186
|
+
let j = i;
|
|
187
|
+
while (j < raw.length && /\s/.test(raw[j])) j++;
|
|
188
|
+
if (raw[j] === ':') out += '"' + raw.slice(keyStart, i) + '"';
|
|
189
|
+
else out += raw.slice(keyStart, i);
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
out += c;
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(out);
|
|
198
|
+
return (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) ? parsed : {};
|
|
199
|
+
} catch {
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseGemmaNative(text: string): ParseResult {
|
|
205
|
+
const calls = extractGemmaToolCalls(text); // uses the exported helper above
|
|
206
|
+
const content: ContentBlock[] = [];
|
|
207
|
+
for (let idx = 0; idx < calls.length; idx++) {
|
|
208
|
+
const c = calls[idx];
|
|
209
|
+
content.push({
|
|
210
|
+
type: 'tool_use',
|
|
211
|
+
id: `tc-${Date.now()}-${idx}`,
|
|
212
|
+
name: c.name,
|
|
213
|
+
input: parseGemmaArgs(c.argsBlock),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (calls.length === 0) {
|
|
217
|
+
const cleanText = text.replace(/<\|tool_call>[\s\S]*?<tool_call\|>/g, '').trim();
|
|
218
|
+
content.push({ type: 'text', text: cleanText || text });
|
|
219
|
+
return { content, foundToolCall: false };
|
|
220
|
+
}
|
|
221
|
+
// Preserve any prose around the tool calls (strip the call blocks).
|
|
222
|
+
const prose = text.replace(/<\|tool_call>[\s\S]*?<tool_call\|>/g, '').trim();
|
|
223
|
+
if (prose) {
|
|
224
|
+
content.unshift({ type: 'text', text: prose });
|
|
225
|
+
}
|
|
226
|
+
return { content, foundToolCall: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
230
|
+
// Qwen 3/3.5 ChatML tool-call format
|
|
231
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function parseQwenJson(text: string): ParseResult {
|
|
234
|
+
// Strip <think>...</think> thinking tokens from prose (they shouldn't leak).
|
|
235
|
+
const noThink = text.replace(/<think>[\s\S]*?<\/think>/g, '');
|
|
236
|
+
|
|
237
|
+
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
|
238
|
+
const content: ContentBlock[] = [];
|
|
239
|
+
const matches: Array<{ name: string; input: Record<string, unknown> }> = [];
|
|
240
|
+
let m: RegExpExecArray | null;
|
|
241
|
+
while ((m = re.exec(noThink)) !== null) {
|
|
242
|
+
try {
|
|
243
|
+
const obj = JSON.parse(m[1]);
|
|
244
|
+
if (obj && typeof obj === 'object' && typeof obj.name === 'string') {
|
|
245
|
+
matches.push({
|
|
246
|
+
name: obj.name,
|
|
247
|
+
input: (obj.arguments ?? {}) as Record<string, unknown>,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// Skip unparseable tool_call blocks.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const prose = noThink.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '').trim();
|
|
256
|
+
|
|
257
|
+
if (matches.length === 0) {
|
|
258
|
+
content.push({ type: 'text', text: prose || noThink.trim() });
|
|
259
|
+
return { content, foundToolCall: false };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (prose) {
|
|
263
|
+
content.push({ type: 'text', text: prose });
|
|
264
|
+
}
|
|
265
|
+
for (let i = 0; i < matches.length; i++) {
|
|
266
|
+
content.push({
|
|
267
|
+
type: 'tool_use',
|
|
268
|
+
id: `tc-${Date.now()}-${i}`,
|
|
269
|
+
name: matches[i].name,
|
|
270
|
+
input: matches[i].input,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return { content, foundToolCall: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
277
|
+
// Mistral [TOOL_CALLS][...] format
|
|
278
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
function parseMistralToolCalls(text: string): ParseResult {
|
|
281
|
+
const re = /\[TOOL_CALLS\]\s*(\[[\s\S]*?\])/g;
|
|
282
|
+
const content: ContentBlock[] = [];
|
|
283
|
+
const matches: Array<{ name: string; input: Record<string, unknown> }> = [];
|
|
284
|
+
let m: RegExpExecArray | null;
|
|
285
|
+
while ((m = re.exec(text)) !== null) {
|
|
286
|
+
try {
|
|
287
|
+
const arr = JSON.parse(m[1]);
|
|
288
|
+
if (Array.isArray(arr)) {
|
|
289
|
+
for (const entry of arr) {
|
|
290
|
+
if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
291
|
+
matches.push({
|
|
292
|
+
name: entry.name,
|
|
293
|
+
input: (entry.arguments ?? {}) as Record<string, unknown>,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// Skip unparseable [TOOL_CALLS] blocks.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const prose = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*?\]/g, '').trim();
|
|
304
|
+
|
|
305
|
+
if (matches.length === 0) {
|
|
306
|
+
content.push({ type: 'text', text: prose || text.trim() });
|
|
307
|
+
return { content, foundToolCall: false };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (prose) {
|
|
311
|
+
content.push({ type: 'text', text: prose });
|
|
312
|
+
}
|
|
313
|
+
for (let i = 0; i < matches.length; i++) {
|
|
314
|
+
content.push({
|
|
315
|
+
type: 'tool_use',
|
|
316
|
+
id: `tc-${Date.now()}-${i}`,
|
|
317
|
+
name: matches[i].name,
|
|
318
|
+
input: matches[i].input,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return { content, foundToolCall: true };
|
|
322
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Collect per-layer prompt references (tool names formatted for the LLM).
|
|
2
|
+
|
|
3
|
+
import type { ProviderTool } from '../types.js';
|
|
4
|
+
import {
|
|
5
|
+
buildToolsFromLayers,
|
|
6
|
+
protocolToken,
|
|
7
|
+
resolveCanonicalTools,
|
|
8
|
+
sanitizeServerName,
|
|
9
|
+
type McpLayer,
|
|
10
|
+
type ProviderKind,
|
|
11
|
+
type ToolLayer,
|
|
12
|
+
type WebMcpLayer,
|
|
13
|
+
} from '../tool-layers.js';
|
|
14
|
+
import { formatGemmaToolDeclaration } from './gemma4-prompt-builder.js';
|
|
15
|
+
|
|
16
|
+
/** Short descriptions for discovery pseudo-tools (used in inline Gemma decls). */
|
|
17
|
+
const shortSearchToolsDesc = (serverName: string) =>
|
|
18
|
+
`Search tools by keyword on the ${serverName} server.`;
|
|
19
|
+
const shortListToolsDesc = (serverName: string) =>
|
|
20
|
+
`List ALL tools on the ${serverName} server.`;
|
|
21
|
+
|
|
22
|
+
/** First parameter name from a tool's input schema (for arg hints). */
|
|
23
|
+
function firstParamName(tool?: ProviderTool, fallback = 'query'): string {
|
|
24
|
+
if (!tool?.input_schema) return fallback;
|
|
25
|
+
const schema = tool.input_schema as Record<string, unknown>;
|
|
26
|
+
const required = (schema.required as string[] | undefined) ?? [];
|
|
27
|
+
if (required.length > 0) return required[0];
|
|
28
|
+
const props = (schema.properties as Record<string, unknown> | undefined) ?? {};
|
|
29
|
+
return Object.keys(props)[0] ?? fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Format a tool reference for the system prompt (generic or inline Gemma decl). */
|
|
33
|
+
function fmtToolRef(
|
|
34
|
+
prefixedName: string,
|
|
35
|
+
args: string[] = [],
|
|
36
|
+
kind: ProviderKind = 'generic',
|
|
37
|
+
tool?: ProviderTool,
|
|
38
|
+
): string {
|
|
39
|
+
if (kind === 'gemma' && tool) {
|
|
40
|
+
return formatGemmaToolDeclaration({ ...tool, name: prefixedName });
|
|
41
|
+
}
|
|
42
|
+
if (kind === 'gemma' || kind === 'qwen' || kind === 'mistral') {
|
|
43
|
+
return args.length ? `\`${prefixedName}(${args.join(', ')})\`` : `\`${prefixedName}\``;
|
|
44
|
+
}
|
|
45
|
+
return args.length ? `${prefixedName}(${args.join(', ')})` : `${prefixedName}()`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PromptRefs {
|
|
49
|
+
searchRecipes: string[];
|
|
50
|
+
listRecipes: string[];
|
|
51
|
+
getRecipes: string[];
|
|
52
|
+
searchTools: string[];
|
|
53
|
+
listTools: string[];
|
|
54
|
+
actionTools: string[];
|
|
55
|
+
listRecipesByCat: { data: string[]; display: string[] };
|
|
56
|
+
searchRecipesByCat: { data: string[]; display: string[] };
|
|
57
|
+
getRecipesByCat: { data: string[]; display: string[] };
|
|
58
|
+
aliasMap: Map<string, string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function collectPromptRefs(
|
|
62
|
+
layers: ToolLayer[],
|
|
63
|
+
providerKind: ProviderKind,
|
|
64
|
+
): PromptRefs {
|
|
65
|
+
const kind = providerKind;
|
|
66
|
+
const mcpLayers = layers.filter((l): l is McpLayer => l.protocol === 'mcp');
|
|
67
|
+
const webmcpLayers = layers.filter((l): l is WebMcpLayer => l.protocol === 'webmcp');
|
|
68
|
+
|
|
69
|
+
const displayLayers = webmcpLayers.filter(l => l.tools.some(t => t.name === 'widget_display'));
|
|
70
|
+
const dataLayers = layers.filter(l => !displayLayers.includes(l as WebMcpLayer));
|
|
71
|
+
|
|
72
|
+
const providerToolsByName = new Map<string, ProviderTool>(
|
|
73
|
+
buildToolsFromLayers(layers, { sanitize: true }).tools.map(t => [t.name, t]),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const aliasMap = new Map<string, string>();
|
|
77
|
+
const searchRecipes: string[] = [];
|
|
78
|
+
const listRecipes: string[] = [];
|
|
79
|
+
const getRecipes: string[] = [];
|
|
80
|
+
const searchTools: string[] = [];
|
|
81
|
+
const listTools: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const l of webmcpLayers) {
|
|
84
|
+
const prefix = `${sanitizeServerName(l.serverName)}_ui_`;
|
|
85
|
+
for (const t of l.tools) {
|
|
86
|
+
if (t.name === 'search_recipes') {
|
|
87
|
+
const name = `${prefix}search_recipes`;
|
|
88
|
+
const toolDef = providerToolsByName.get(name);
|
|
89
|
+
searchRecipes.push(fmtToolRef(name, [firstParamName(toolDef, 'query')], kind, toolDef));
|
|
90
|
+
}
|
|
91
|
+
if (t.name === 'list_recipes') {
|
|
92
|
+
const name = `${prefix}list_recipes`;
|
|
93
|
+
const toolDef = providerToolsByName.get(name);
|
|
94
|
+
listRecipes.push(fmtToolRef(name, [], kind, toolDef));
|
|
95
|
+
}
|
|
96
|
+
if (t.name === 'get_recipe') {
|
|
97
|
+
const name = `${prefix}get_recipe`;
|
|
98
|
+
const toolDef = providerToolsByName.get(name);
|
|
99
|
+
getRecipes.push(fmtToolRef(name, [firstParamName(toolDef, 'id')], kind, toolDef));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const searchToolsPseudo: ProviderTool = {
|
|
103
|
+
name: `${prefix}search_tools`,
|
|
104
|
+
description: shortSearchToolsDesc(l.serverName),
|
|
105
|
+
input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for.' } }, required: ['query'] },
|
|
106
|
+
};
|
|
107
|
+
searchTools.push(fmtToolRef(`${prefix}search_tools`, ['query'], kind, searchToolsPseudo));
|
|
108
|
+
const listToolsPseudo: ProviderTool = {
|
|
109
|
+
name: `${prefix}list_tools`,
|
|
110
|
+
description: shortListToolsDesc(l.serverName),
|
|
111
|
+
input_schema: { type: 'object', properties: {} },
|
|
112
|
+
};
|
|
113
|
+
listTools.push(fmtToolRef(`${prefix}list_tools`, [], kind, listToolsPseudo));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const l of mcpLayers) {
|
|
117
|
+
const prefix = `${sanitizeServerName(l.serverName)}_data_`;
|
|
118
|
+
const matches = resolveCanonicalTools(l.tools);
|
|
119
|
+
|
|
120
|
+
for (const m of matches) {
|
|
121
|
+
const canonicalPrefixed = `${prefix}${m.role}`;
|
|
122
|
+
const realPrefixed = `${prefix}${m.realToolName}`;
|
|
123
|
+
|
|
124
|
+
if (m.role !== m.realToolName) {
|
|
125
|
+
aliasMap.set(canonicalPrefixed, realPrefixed);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const realToolDef = providerToolsByName.get(realPrefixed);
|
|
129
|
+
|
|
130
|
+
if (m.role === 'search_recipes') {
|
|
131
|
+
searchRecipes.push(fmtToolRef(canonicalPrefixed, [firstParamName(realToolDef, 'query')], kind, realToolDef));
|
|
132
|
+
}
|
|
133
|
+
if (m.role === 'list_recipes') {
|
|
134
|
+
listRecipes.push(fmtToolRef(canonicalPrefixed, [], kind, realToolDef));
|
|
135
|
+
}
|
|
136
|
+
if (m.role === 'get_recipe') {
|
|
137
|
+
getRecipes.push(fmtToolRef(canonicalPrefixed, [firstParamName(realToolDef, 'id')], kind, realToolDef));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const searchToolsPseudo: ProviderTool = {
|
|
142
|
+
name: `${prefix}search_tools`,
|
|
143
|
+
description: shortSearchToolsDesc(l.serverName),
|
|
144
|
+
input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for.' } }, required: ['query'] },
|
|
145
|
+
};
|
|
146
|
+
searchTools.push(fmtToolRef(`${prefix}search_tools`, ['query'], kind, searchToolsPseudo));
|
|
147
|
+
const listToolsPseudo: ProviderTool = {
|
|
148
|
+
name: `${prefix}list_tools`,
|
|
149
|
+
description: shortListToolsDesc(l.serverName),
|
|
150
|
+
input_schema: { type: 'object', properties: {} },
|
|
151
|
+
};
|
|
152
|
+
listTools.push(fmtToolRef(`${prefix}list_tools`, [], kind, listToolsPseudo));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const actionTools: string[] = [];
|
|
156
|
+
const ACTION_NAMES = ['widget_display', 'canvas', 'recall'];
|
|
157
|
+
for (const l of webmcpLayers) {
|
|
158
|
+
const prefix = `${sanitizeServerName(l.serverName)}_ui_`;
|
|
159
|
+
for (const actionName of ACTION_NAMES) {
|
|
160
|
+
if (l.tools.some(t => t.name === actionName)) {
|
|
161
|
+
const prefixedName = `${prefix}${actionName}`;
|
|
162
|
+
const toolDef = providerToolsByName.get(prefixedName);
|
|
163
|
+
const args = actionName === 'widget_display' ? ['name', 'params'] : [];
|
|
164
|
+
actionTools.push(fmtToolRef(prefixedName, args, kind, toolDef));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const dataPrefixes = new Set(dataLayers.map(l => `${sanitizeServerName(l.serverName)}_${protocolToken(l.protocol)}_`));
|
|
170
|
+
const displayPrefixes = new Set(displayLayers.map(l => `${sanitizeServerName(l.serverName)}_ui_`));
|
|
171
|
+
void dataPrefixes;
|
|
172
|
+
|
|
173
|
+
function splitByCategory(refs: string[]): { data: string[]; display: string[] } {
|
|
174
|
+
const data: string[] = [];
|
|
175
|
+
const display: string[] = [];
|
|
176
|
+
for (const ref of refs) {
|
|
177
|
+
const isDisplay = [...displayPrefixes].some(p => ref.includes(p));
|
|
178
|
+
if (isDisplay) display.push(ref);
|
|
179
|
+
else data.push(ref);
|
|
180
|
+
}
|
|
181
|
+
return { data, display };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
searchRecipes,
|
|
186
|
+
listRecipes,
|
|
187
|
+
getRecipes,
|
|
188
|
+
searchTools,
|
|
189
|
+
listTools,
|
|
190
|
+
actionTools,
|
|
191
|
+
listRecipesByCat: splitByCategory(listRecipes),
|
|
192
|
+
searchRecipesByCat: splitByCategory(searchRecipes),
|
|
193
|
+
getRecipesByCat: splitByCategory(getRecipes),
|
|
194
|
+
aliasMap,
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/providers/factory.ts
CHANGED
|
@@ -2,15 +2,36 @@ import type { LLMProvider, RemoteModelId, WasmModelId } from '../types.js';
|
|
|
2
2
|
import { RemoteLLMProvider } from './remote.js';
|
|
3
3
|
import { WasmProvider } from './wasm.js';
|
|
4
4
|
import { LocalLLMProvider, type LocalBackend } from './local.js';
|
|
5
|
+
import { TransformersProvider } from './transformers.js';
|
|
6
|
+
import { HawkProvider } from './hawk.js';
|
|
5
7
|
|
|
6
8
|
export type LLMConfig =
|
|
7
|
-
| { type: 'remote';
|
|
8
|
-
| { type: 'wasm';
|
|
9
|
-
| { type: '
|
|
9
|
+
| { type: 'remote'; model?: RemoteModelId; proxyUrl?: string; apiKey?: string }
|
|
10
|
+
| { type: 'wasm'; model?: WasmModelId; onProgress?: (loaded: number, total: number) => void }
|
|
11
|
+
| { type: 'transformers'; model: string; onProgress?: (loaded: number, total: number) => void }
|
|
12
|
+
| { type: 'local'; model: string; baseUrl: string; backend?: LocalBackend }
|
|
13
|
+
| { type: 'hawk'; model: string; proxyUrl?: string };
|
|
10
14
|
|
|
11
15
|
export function createProvider(config: LLMConfig): LLMProvider {
|
|
12
16
|
const base = typeof window !== 'undefined' ? (document.querySelector('base') as HTMLBaseElement | null)?.href ?? '' : '';
|
|
13
17
|
|
|
18
|
+
// Prefix-based dispatch: a `transformers-*` model routes to TransformersProvider
|
|
19
|
+
// regardless of the declared type (defensive).
|
|
20
|
+
if ('model' in config && typeof config.model === 'string' && config.model.startsWith('hawk-')) {
|
|
21
|
+
return new HawkProvider({
|
|
22
|
+
proxyUrl: (config as { proxyUrl?: string }).proxyUrl ?? `${base}api/hawk`,
|
|
23
|
+
model: config.model.slice(5),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ('model' in config && typeof config.model === 'string' && config.model.startsWith('transformers-')) {
|
|
28
|
+
const onProgress = (config as { onProgress?: (loaded: number, total: number) => void }).onProgress;
|
|
29
|
+
return new TransformersProvider({
|
|
30
|
+
model: config.model,
|
|
31
|
+
onProgress: onProgress ? (_progress, _status, loaded, total) => onProgress(loaded ?? 0, total ?? 0) : undefined,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
switch (config.type) {
|
|
15
36
|
case 'remote':
|
|
16
37
|
return new RemoteLLMProvider({
|
|
@@ -23,11 +44,21 @@ export function createProvider(config: LLMConfig): LLMProvider {
|
|
|
23
44
|
model: config.model,
|
|
24
45
|
onProgress: config.onProgress ? (progress, _status, loaded, total) => config.onProgress!(loaded ?? 0, total ?? 0) : undefined,
|
|
25
46
|
});
|
|
47
|
+
case 'transformers':
|
|
48
|
+
return new TransformersProvider({
|
|
49
|
+
model: config.model,
|
|
50
|
+
onProgress: config.onProgress ? (_progress, _status, loaded, total) => config.onProgress!(loaded ?? 0, total ?? 0) : undefined,
|
|
51
|
+
});
|
|
26
52
|
case 'local':
|
|
27
53
|
return new LocalLLMProvider({
|
|
28
54
|
baseUrl: config.baseUrl,
|
|
29
55
|
model: config.model,
|
|
30
56
|
backend: config.backend,
|
|
31
57
|
});
|
|
58
|
+
case 'hawk':
|
|
59
|
+
return new HawkProvider({
|
|
60
|
+
proxyUrl: config.proxyUrl ?? `${base}api/hawk`,
|
|
61
|
+
model: config.model,
|
|
62
|
+
});
|
|
32
63
|
}
|
|
33
64
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface HawkModelEntry {
|
|
2
|
+
id: string; // ID Hawk (sans préfixe)
|
|
3
|
+
label: string; // Label humain pour le selector
|
|
4
|
+
tokps?: number; // Tokens/sec estimés (warm, indicatif)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const HAWK_MODELS: HawkModelEntry[] = [
|
|
8
|
+
{ id: 'qwen35-2b', label: 'Qwen 3.5 2B — 49 tok/s', tokps: 49 },
|
|
9
|
+
{ id: 'bielik-1.5b-v3', label: 'Bielik 1.5B — 47 tok/s', tokps: 47 },
|
|
10
|
+
{ id: 'gemma4-e2b', label: 'Gemma 4 E2B — 43 tok/s', tokps: 43 },
|
|
11
|
+
{ id: 'ministral3-3b', label: 'Ministral 3B — 35 tok/s', tokps: 35 },
|
|
12
|
+
{ id: 'qwen3-4b', label: 'Qwen 3 4B — 28 tok/s', tokps: 28 },
|
|
13
|
+
{ id: 'gemma4-e4b', label: 'Gemma 4 E4B — 26 tok/s', tokps: 26 },
|
|
14
|
+
{ id: 'qwen35-4b', label: 'Qwen 3.5 4B — 23 tok/s', tokps: 23 },
|
|
15
|
+
{ id: 'qwen36-35b-a3b', label: 'Qwen 3.6 35B MoE — 22 tok/s', tokps: 22 },
|
|
16
|
+
{ id: 'gemma4-26b-a4b', label: 'Gemma 4 26B MoE — 20 tok/s', tokps: 20 },
|
|
17
|
+
{ id: 'ministral-8b', label: 'Ministral 8B — 16 tok/s', tokps: 16 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function listHawkModels(): HawkModelEntry[] {
|
|
21
|
+
return HAWK_MODELS;
|
|
22
|
+
}
|