@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.
@@ -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
+ }
@@ -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'; model?: RemoteModelId; proxyUrl?: string; apiKey?: string }
8
- | { type: 'wasm'; model?: WasmModelId; onProgress?: (loaded: number, total: number) => void }
9
- | { type: 'local'; model: string; baseUrl: string; backend?: LocalBackend };
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
+ }