@webmcp-auto-ui/agent 2.5.27 → 2.5.29
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 +57 -84
- package/src/index.ts +7 -2
- package/src/loop.ts +53 -23
- package/src/providers/factory.ts +15 -1
- package/src/providers/hawk-models.ts +23 -0
- package/src/providers/hawk.ts +181 -0
- package/src/providers/transformers.worker.ts +5 -32
- package/src/providers/wasm.ts +7 -5
- package/src/recipes/_generated.ts +64 -54
- package/src/recipes/hackathon-assemblee-nationale.md +4 -10
- package/src/recipes/notebook-playbook.md +60 -44
- package/src/server/hawkProxy.ts +54 -0
- package/src/server/index.ts +2 -0
- package/src/tool-layers.ts +26 -4
- package/src/trace-observer.ts +4 -15
- package/src/util/opfs-cache.ts +101 -2
- package/src/util/storage-inventory.ts +195 -0
- package/src/notebook-widgets/compact.ts +0 -312
- package/src/notebook-widgets/document.ts +0 -372
- package/src/notebook-widgets/editorial.ts +0 -348
- package/src/notebook-widgets/recipes/compact.md +0 -104
- package/src/notebook-widgets/recipes/document.md +0 -100
- package/src/notebook-widgets/recipes/editorial.md +0 -104
- package/src/notebook-widgets/recipes/workspace.md +0 -94
- package/src/notebook-widgets/shared.ts +0 -1064
- package/src/notebook-widgets/workspace.ts +0 -328
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webmcp-auto-ui/agent",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.29",
|
|
4
4
|
"description": "LLM agent loop + remote/WASM/local providers + MCP wrapper",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"import": "./src/index.ts"
|
|
12
12
|
},
|
|
13
13
|
"./server": {
|
|
14
|
-
"import": "./src/server/
|
|
14
|
+
"import": "./src/server/index.ts"
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
@@ -27,5 +27,13 @@
|
|
|
27
27
|
"@webmcp-auto-ui/core": "file:../core",
|
|
28
28
|
"onnxruntime-web": "^1.24.3",
|
|
29
29
|
"typescript": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"vega-embed": "^6.24.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"vega-embed": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
30
38
|
}
|
|
31
39
|
}
|
package/src/autoui-server.ts
CHANGED
|
@@ -5,21 +5,55 @@
|
|
|
5
5
|
import { createWebMcpServer, parseFrontmatter } from '@webmcp-auto-ui/core';
|
|
6
6
|
import { RAW_RECIPES } from './recipes/_generated.js';
|
|
7
7
|
|
|
8
|
-
// Notebook widget
|
|
8
|
+
// Notebook widget recipe (vanilla renderer) — moved to @webmcp-auto-ui/ui
|
|
9
9
|
// @ts-ignore — Vite raw imports, not resolved by tsc
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
//
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
import notebookRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/notebook.md?raw';
|
|
11
|
+
|
|
12
|
+
// Notebook widget renderer (vanilla JS) — import via subpath to avoid pulling
|
|
13
|
+
// the .svelte exports of the ui package root through tsc.
|
|
14
|
+
import { render as renderNotebook } from '@webmcp-auto-ui/ui/widgets/notebook/notebook.js';
|
|
15
|
+
import { render as renderRecipeBrowser } from '@webmcp-auto-ui/ui/widgets/notebook/recipe-browser.js';
|
|
16
|
+
|
|
17
|
+
// Inline recipe for recipe-browser (real vanilla widget)
|
|
18
|
+
const recipeBrowserRecipe = `---
|
|
19
|
+
widget: recipe-browser
|
|
20
|
+
description: Interactive recipe browser with search, kind/tag filters, preview and pick. Use when the user wants to browse, search, or select recipes from connected servers.
|
|
21
|
+
group: rich
|
|
22
|
+
schema:
|
|
23
|
+
type: object
|
|
24
|
+
required:
|
|
25
|
+
- recipes
|
|
26
|
+
properties:
|
|
27
|
+
recipes:
|
|
28
|
+
type: array
|
|
29
|
+
description: List of Recipe objects (id, name, description, body, servers, ...).
|
|
30
|
+
items:
|
|
31
|
+
type: object
|
|
32
|
+
filters:
|
|
33
|
+
type: object
|
|
34
|
+
description: Initial filters
|
|
35
|
+
properties:
|
|
36
|
+
q:
|
|
37
|
+
type: string
|
|
38
|
+
kind:
|
|
39
|
+
type: string
|
|
40
|
+
enum: [all, webmcp, mcp]
|
|
41
|
+
tags:
|
|
42
|
+
type: array
|
|
43
|
+
items:
|
|
44
|
+
type: string
|
|
45
|
+
layout:
|
|
46
|
+
type: string
|
|
47
|
+
enum: [list, grid]
|
|
48
|
+
description: Default layout (default list)
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## When to use
|
|
52
|
+
When the user wants to browse, search, or pick a recipe — for example "show me the available recipes" or "let me choose a recipe".
|
|
53
|
+
|
|
54
|
+
## How to use
|
|
55
|
+
Call widget_display({name: "recipe-browser", params: {recipes: [...], layout: "list"}}). The widget emits a bubbling 'widget:interact' CustomEvent with detail={action:"pick", payload: recipe} when the user clicks Pick.
|
|
56
|
+
`;
|
|
23
57
|
|
|
24
58
|
// ---------------------------------------------------------------------------
|
|
25
59
|
// Inline recipes (frontmatter + body)
|
|
@@ -936,77 +970,18 @@ Pour des visualisations custom, animations, ou prototypes interactifs en JS pur.
|
|
|
936
970
|
Call widget_display({name: "js-sandbox", params: {code: "document.getElementById('root').innerHTML = '<h1>Hello</h1>'"}}).
|
|
937
971
|
`,
|
|
938
972
|
|
|
939
|
-
// ── recipe-browser ──────────────────────────────────────────────────────
|
|
940
|
-
`---
|
|
941
|
-
widget: recipe-browser
|
|
942
|
-
description: Displays available recipes as interactive cards and allows browsing each recipe's details.
|
|
943
|
-
group: rich
|
|
944
|
-
schema:
|
|
945
|
-
type: object
|
|
946
|
-
required:
|
|
947
|
-
- cards
|
|
948
|
-
properties:
|
|
949
|
-
title:
|
|
950
|
-
type: string
|
|
951
|
-
cards:
|
|
952
|
-
type: array
|
|
953
|
-
items:
|
|
954
|
-
type: object
|
|
955
|
-
required:
|
|
956
|
-
- title
|
|
957
|
-
properties:
|
|
958
|
-
title:
|
|
959
|
-
type: string
|
|
960
|
-
description:
|
|
961
|
-
type: string
|
|
962
|
-
tags:
|
|
963
|
-
type: array
|
|
964
|
-
items:
|
|
965
|
-
type: string
|
|
966
|
-
meta:
|
|
967
|
-
type: object
|
|
968
|
-
properties:
|
|
969
|
-
recipe_name:
|
|
970
|
-
type: string
|
|
971
|
-
server:
|
|
972
|
-
type: string
|
|
973
|
-
interactive:
|
|
974
|
-
type: boolean
|
|
975
|
-
---
|
|
976
|
-
|
|
977
|
-
## When to use
|
|
978
|
-
Quand l'utilisateur veut voir les recettes disponibles, explorer les possibilites du serveur, ou comprendre comment utiliser un widget specifique.
|
|
979
|
-
|
|
980
|
-
## Comment
|
|
981
|
-
|
|
982
|
-
### Etape 1 — Lister les recettes
|
|
983
|
-
Appelle search_recipes() sur chaque serveur connecte (MCP et WebMCP) pour obtenir la liste des recettes.
|
|
984
|
-
|
|
985
|
-
### Etape 2 — Afficher en cartes interactives
|
|
986
|
-
Utilise widget_display({name: "cards", params: {...}}) avec le parametre interactive: true pour rendre les cartes cliquables :
|
|
987
|
-
widget_display({name: "cards", params: {title: "Recettes disponibles", cards: [{title: "Nom", description: "Description", tags: ["serveur"], meta: {recipe_name: "nom_technique", server: "nom_serveur"}}], interactive: true}})
|
|
988
|
-
|
|
989
|
-
Le champ meta est important : il sera renvoye dans l'evenement d'interaction quand l'utilisateur clique sur la carte.
|
|
990
|
-
|
|
991
|
-
### Etape 3 — Reagir au clic
|
|
992
|
-
Quand l'utilisateur clique sur une carte, tu recevras un message d'interaction contenant les donnees de meta. Utilise meta.recipe_name et meta.server pour :
|
|
993
|
-
1. Appeler get_recipe(meta.recipe_name) sur le bon serveur
|
|
994
|
-
2. Afficher le contenu dans un widget code avec lang: 'markdown'
|
|
995
|
-
3. Lier les deux widgets : reutiliser le widget detail existant via canvas('update', ...) au lieu d'en creer un nouveau a chaque clic.
|
|
996
|
-
|
|
997
|
-
## Common mistakes
|
|
998
|
-
- Ne pas oublier interactive: true dans les cartes — sans ca, les clics ne remontent pas
|
|
999
|
-
- Ne pas creer un nouveau widget detail a chaque clic — reutiliser l'existant via canvas('update', ...)
|
|
1000
|
-
- Les recettes MCP et WebMCP ont des noms de serveur differents — utiliser le bon prefixe pour get_recipe()
|
|
1001
|
-
`,
|
|
1002
973
|
];
|
|
1003
974
|
|
|
1004
975
|
// ---------------------------------------------------------------------------
|
|
1005
976
|
// Native widget names — derived from RECIPES frontmatter
|
|
1006
977
|
// ---------------------------------------------------------------------------
|
|
1007
978
|
|
|
1008
|
-
/** Derived from RECIPES
|
|
1009
|
-
|
|
979
|
+
/** Derived from RECIPES + notebook widget recipes — always in sync with registered widgets */
|
|
980
|
+
const _NOTEBOOK_RECIPE_SOURCES: string[] = [
|
|
981
|
+
notebookRecipe as string,
|
|
982
|
+
recipeBrowserRecipe,
|
|
983
|
+
];
|
|
984
|
+
export const NATIVE_WIDGET_NAMES = [...RECIPES, ..._NOTEBOOK_RECIPE_SOURCES].map(r => {
|
|
1010
985
|
const match = r.match(/widget:\s*(\S+)/);
|
|
1011
986
|
return match ? match[1] : '';
|
|
1012
987
|
}).filter(Boolean) as string[];
|
|
@@ -1024,12 +999,10 @@ for (const recipe of RECIPES) {
|
|
|
1024
999
|
autoui.registerWidget(recipe, undefined);
|
|
1025
1000
|
}
|
|
1026
1001
|
|
|
1027
|
-
// Notebook
|
|
1002
|
+
// Notebook widget — vanilla renderer (resolved via WidgetRenderer vanilla path)
|
|
1028
1003
|
const NOTEBOOK_WIDGETS: Array<[string, (container: HTMLElement, data: any) => any]> = [
|
|
1029
|
-
[
|
|
1030
|
-
[
|
|
1031
|
-
[documentRecipe as string, renderDocument],
|
|
1032
|
-
[editorialRecipe as string, renderEditorial],
|
|
1004
|
+
[notebookRecipe as string, renderNotebook],
|
|
1005
|
+
[recipeBrowserRecipe, renderRecipeBrowser],
|
|
1033
1006
|
];
|
|
1034
1007
|
for (const [recipe, renderer] of NOTEBOOK_WIDGETS) {
|
|
1035
1008
|
autoui.registerWidget(recipe, renderer as any);
|
package/src/index.ts
CHANGED
|
@@ -11,12 +11,17 @@ export { TRANSFORMERS_MODELS, getTransformersModel, listTransformersModels } fro
|
|
|
11
11
|
export type { TransformersModelEntry, TransformersFamily, ToolCallFormat } from './providers/transformers-models.js';
|
|
12
12
|
export { parseToolCalls } from './prompts/tool-call-parsers.js';
|
|
13
13
|
export type { ParseResult } from './prompts/tool-call-parsers.js';
|
|
14
|
-
export { loadOrDownloadModel, clearModelCache } from './util/opfs-cache.js';
|
|
15
|
-
export type { ModelFileSpec, CacheProgress } from './util/opfs-cache.js';
|
|
14
|
+
export { loadOrDownloadModel, clearModelCache, listCachedModels, clearAllModelCaches, walkDirectoryStats } from './util/opfs-cache.js';
|
|
15
|
+
export type { ModelFileSpec, CacheProgress, CachedModelInfo } from './util/opfs-cache.js';
|
|
16
|
+
export { listAllStorage, deleteStorageEntry, clearAllStorage } from './util/storage-inventory.js';
|
|
17
|
+
export type { StorageEntry, StorageSource } from './util/storage-inventory.js';
|
|
16
18
|
export { buildGemmaPrompt } from './prompts/index.js';
|
|
17
19
|
export type { BuildGemmaPromptInput } from './prompts/index.js';
|
|
18
20
|
export { LocalLLMProvider } from './providers/local.js';
|
|
19
21
|
export type { LocalLLMProviderOptions, LocalBackend } from './providers/local.js';
|
|
22
|
+
export { HawkProvider } from './providers/hawk.js';
|
|
23
|
+
export type { HawkLLMProviderOptions } from './providers/hawk.js';
|
|
24
|
+
export { HAWK_MODELS, listHawkModels, type HawkModelEntry } from './providers/hawk-models.js';
|
|
20
25
|
export { createProvider } from './providers/factory.js';
|
|
21
26
|
export type { LLMConfig } from './providers/factory.js';
|
|
22
27
|
|
package/src/loop.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
LLMProvider, ProviderTool, McpToolDef, AgentCallbacks,
|
|
10
10
|
} from './types.js';
|
|
11
11
|
import type { ToolLayer, SchemaTransformOptions } from './tool-layers.js';
|
|
12
|
-
import { buildToolsFromLayers, buildDiscoveryToolsWithAliases, activateServerTools, toProviderTools, sanitizeServerName
|
|
12
|
+
import { buildToolsFromLayers, buildDiscoveryToolsWithAliases, activateServerTools, toProviderTools, sanitizeServerName } from './tool-layers.js';
|
|
13
13
|
import { buildSystemPromptWithAliases, buildSystemPrompt } from './prompts/index.js';
|
|
14
14
|
import type { DiscoveryCache } from './discovery-cache.js';
|
|
15
15
|
import { unflattenParams, validateJsonSchema } from '@webmcp-auto-ui/core';
|
|
@@ -168,6 +168,9 @@ export async function runAgentLoop(
|
|
|
168
168
|
// Use local alias maps (parallel-safe — no global singleton)
|
|
169
169
|
const activatedServers = new Set<string>();
|
|
170
170
|
const localAliasMap = new Map<string, string>();
|
|
171
|
+
// Local pathMaps for flattened schemas — populated as servers are lazily activated
|
|
172
|
+
// (see activateServerTools call below). Parallel-safe: scoped to this loop run.
|
|
173
|
+
const localPathMaps = new Map<string, Record<string, string[]>>();
|
|
171
174
|
const trace = new PipelineTrace();
|
|
172
175
|
|
|
173
176
|
const disc = buildDiscoveryToolsWithAliases(options.layers ?? [], schemaOptions, trace);
|
|
@@ -228,17 +231,24 @@ export async function runAgentLoop(
|
|
|
228
231
|
// After 5+ iterations without render, inject a nudge message (once)
|
|
229
232
|
// Merge into existing user message if the last message is already role=user (to avoid consecutive user messages)
|
|
230
233
|
if (iterationsWithoutRender >= 5 && !hasRendered && !nudgedOnce) {
|
|
231
|
-
nudgedOnce = true;
|
|
232
234
|
const nudgeText = 'STOP exploration. Use the data you already collected. Call widget_display() NOW to display results.';
|
|
233
235
|
const lastMsg = messages[messages.length - 1];
|
|
234
|
-
if
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
// Skip if last turn carries tool_result blocks — mixing raw text with tool_response
|
|
237
|
+
// in one turn violates Gemma spec §7 (the serializer would emit text + <|tool_response|>
|
|
238
|
+
// together). Defer the nudge to a later iteration where the turn is pure-user.
|
|
239
|
+
const lastHasToolResult = lastMsg && Array.isArray(lastMsg.content)
|
|
240
|
+
&& (lastMsg.content as ContentBlock[]).some(b => b.type === 'tool_result');
|
|
241
|
+
if (!lastHasToolResult) {
|
|
242
|
+
nudgedOnce = true;
|
|
243
|
+
if (lastMsg && lastMsg.role === 'user') {
|
|
244
|
+
if (typeof lastMsg.content === 'string') {
|
|
245
|
+
lastMsg.content = [{ type: 'text', text: lastMsg.content }, { type: 'text', text: nudgeText }];
|
|
246
|
+
} else if (Array.isArray(lastMsg.content)) {
|
|
247
|
+
(lastMsg.content as ContentBlock[]).push({ type: 'text', text: nudgeText });
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
messages.push({ role: 'user', content: nudgeText });
|
|
239
251
|
}
|
|
240
|
-
} else {
|
|
241
|
-
messages.push({ role: 'user', content: nudgeText });
|
|
242
252
|
}
|
|
243
253
|
}
|
|
244
254
|
|
|
@@ -388,7 +398,10 @@ export async function runAgentLoop(
|
|
|
388
398
|
activatedServers.add(serverKey);
|
|
389
399
|
const layer = (options.layers ?? []).find(l => sanitizeServerName(l.serverName) === serverName && l.protocol === protocol);
|
|
390
400
|
if (layer) {
|
|
391
|
-
|
|
401
|
+
const act = activateServerTools(activeTools, layer, schemaOptions, trace);
|
|
402
|
+
activeTools = act.tools;
|
|
403
|
+
// Merge new pathMaps so unflattenParams works for lazily-activated tools.
|
|
404
|
+
for (const [k, v] of act.pathMaps) localPathMaps.set(k, v);
|
|
392
405
|
}
|
|
393
406
|
}
|
|
394
407
|
}
|
|
@@ -401,8 +414,11 @@ export async function runAgentLoop(
|
|
|
401
414
|
const protocol = tokenToProtocol(token);
|
|
402
415
|
|
|
403
416
|
// Auto-repair + validate params before dispatch
|
|
417
|
+
// Resolve from the full activeTools (not iterationTools, which may be filtered
|
|
418
|
+
// to strip discovery tools after 4 iterations — would make toolDef undefined
|
|
419
|
+
// and silently skip auto-repair + schema validation).
|
|
404
420
|
let toolInput = block.input as Record<string, unknown>;
|
|
405
|
-
const toolDef =
|
|
421
|
+
const toolDef = activeTools.find(t => t.name === block.name);
|
|
406
422
|
if (toolDef?.input_schema) {
|
|
407
423
|
const repair = autoRepairParams(toolInput, toolDef.input_schema, realToolName);
|
|
408
424
|
if (repair.fixes.length > 0) {
|
|
@@ -451,8 +467,9 @@ export async function runAgentLoop(
|
|
|
451
467
|
result = `Error: no WebMCP server "${serverName}" found.`;
|
|
452
468
|
} else {
|
|
453
469
|
// Unflatten params if schema was flattened
|
|
470
|
+
// Use the local snapshot (parallel-safe) rather than the global singleton.
|
|
454
471
|
if (schemaOptions?.flatten) {
|
|
455
|
-
const pathMap =
|
|
472
|
+
const pathMap = localPathMaps.get(block.name);
|
|
456
473
|
if (pathMap) {
|
|
457
474
|
toolInput = unflattenParams(toolInput, pathMap);
|
|
458
475
|
}
|
|
@@ -638,18 +655,31 @@ export function trimConversationHistory(history: ChatMessage[], maxTokens: numbe
|
|
|
638
655
|
total -= removed.reduce((s, m) => s + JSON.stringify(m).length, 0);
|
|
639
656
|
}
|
|
640
657
|
|
|
641
|
-
// Remove orphaned tool_result
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
658
|
+
// Remove orphaned tool_result blocks anywhere in history — strict providers
|
|
659
|
+
// (Anthropic, etc.) reject tool_result blocks whose tool_use_id does not
|
|
660
|
+
// correspond to an earlier assistant tool_use. Head-only pruning misses
|
|
661
|
+
// internal orphans caused by mid-history trims.
|
|
662
|
+
const validToolUseIds = new Set<string>();
|
|
663
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
664
|
+
const msg = trimmed[i];
|
|
665
|
+
// Collect tool_use ids from assistant messages seen so far
|
|
666
|
+
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
667
|
+
for (const b of msg.content as any[]) {
|
|
668
|
+
if (b?.type === 'tool_use' && typeof b.id === 'string') validToolUseIds.add(b.id);
|
|
669
|
+
}
|
|
652
670
|
}
|
|
671
|
+
// Filter out orphan tool_result blocks in user messages
|
|
672
|
+
if (msg.role === 'user' && Array.isArray(msg.content)) {
|
|
673
|
+
msg.content = (msg.content as any[]).filter(b => {
|
|
674
|
+
if (b?.type !== 'tool_result') return true;
|
|
675
|
+
return typeof b.tool_use_id === 'string' && validToolUseIds.has(b.tool_use_id);
|
|
676
|
+
}) as any;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Drop user messages that became empty after orphan-pruning
|
|
680
|
+
for (let i = trimmed.length - 1; i >= 0; i--) {
|
|
681
|
+
const c = trimmed[i].content;
|
|
682
|
+
if (Array.isArray(c) && c.length === 0) trimmed.splice(i, 1);
|
|
653
683
|
}
|
|
654
684
|
|
|
655
685
|
// Ensure the first non-system message is role=user (API requirement)
|
package/src/providers/factory.ts
CHANGED
|
@@ -3,18 +3,27 @@ import { RemoteLLMProvider } from './remote.js';
|
|
|
3
3
|
import { WasmProvider } from './wasm.js';
|
|
4
4
|
import { LocalLLMProvider, type LocalBackend } from './local.js';
|
|
5
5
|
import { TransformersProvider } from './transformers.js';
|
|
6
|
+
import { HawkProvider } from './hawk.js';
|
|
6
7
|
|
|
7
8
|
export type LLMConfig =
|
|
8
9
|
| { type: 'remote'; model?: RemoteModelId; proxyUrl?: string; apiKey?: string }
|
|
9
10
|
| { type: 'wasm'; model?: WasmModelId; onProgress?: (loaded: number, total: number) => void }
|
|
10
11
|
| { type: 'transformers'; model: string; onProgress?: (loaded: number, total: number) => void }
|
|
11
|
-
| { type: 'local'; model: string; baseUrl: string; backend?: LocalBackend }
|
|
12
|
+
| { type: 'local'; model: string; baseUrl: string; backend?: LocalBackend }
|
|
13
|
+
| { type: 'hawk'; model: string; proxyUrl?: string };
|
|
12
14
|
|
|
13
15
|
export function createProvider(config: LLMConfig): LLMProvider {
|
|
14
16
|
const base = typeof window !== 'undefined' ? (document.querySelector('base') as HTMLBaseElement | null)?.href ?? '' : '';
|
|
15
17
|
|
|
16
18
|
// Prefix-based dispatch: a `transformers-*` model routes to TransformersProvider
|
|
17
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
|
+
|
|
18
27
|
if ('model' in config && typeof config.model === 'string' && config.model.startsWith('transformers-')) {
|
|
19
28
|
const onProgress = (config as { onProgress?: (loaded: number, total: number) => void }).onProgress;
|
|
20
29
|
return new TransformersProvider({
|
|
@@ -46,5 +55,10 @@ export function createProvider(config: LLMConfig): LLMProvider {
|
|
|
46
55
|
model: config.model,
|
|
47
56
|
backend: config.backend,
|
|
48
57
|
});
|
|
58
|
+
case 'hawk':
|
|
59
|
+
return new HawkProvider({
|
|
60
|
+
proxyUrl: config.proxyUrl ?? `${base}api/hawk`,
|
|
61
|
+
model: config.model,
|
|
62
|
+
});
|
|
49
63
|
}
|
|
50
64
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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: 'deepseek-coder-16b', label: 'DeepSeek Coder 16B — 41 tok/s', tokps: 41 },
|
|
12
|
+
{ id: 'ministral3-3b', label: 'Ministral 3B — 35 tok/s', tokps: 35 },
|
|
13
|
+
{ id: 'qwen3-4b', label: 'Qwen 3 4B — 28 tok/s', tokps: 28 },
|
|
14
|
+
{ id: 'gemma4-e4b', label: 'Gemma 4 E4B — 26 tok/s', tokps: 26 },
|
|
15
|
+
{ id: 'qwen35-4b', label: 'Qwen 3.5 4B — 23 tok/s', tokps: 23 },
|
|
16
|
+
{ id: 'qwen36-35b-a3b', label: 'Qwen 3.6 35B MoE — 22 tok/s', tokps: 22 },
|
|
17
|
+
{ id: 'gemma4-26b-a4b', label: 'Gemma 4 26B MoE — 20 tok/s', tokps: 20 },
|
|
18
|
+
{ id: 'ministral-8b', label: 'Ministral 8B — 16 tok/s', tokps: 16 },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function listHawkModels(): HawkModelEntry[] {
|
|
22
|
+
return HAWK_MODELS;
|
|
23
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { LLMProvider, LLMResponse, ChatMessage, ProviderTool, ContentBlock } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export interface HawkLLMProviderOptions {
|
|
4
|
+
proxyUrl: string; // SvelteKit proxy endpoint, e.g. '/api/hawk'
|
|
5
|
+
model: string; // e.g. 'qwen35-2b' (ID Hawk sans préfixe)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// ── OpenAI-compatible types ─────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
interface OaiTool {
|
|
11
|
+
type: 'function';
|
|
12
|
+
function: { name: string; description: string; parameters: Record<string, unknown> };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface OaiMessage {
|
|
16
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
17
|
+
content?: string | null;
|
|
18
|
+
tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[];
|
|
19
|
+
tool_call_id?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface OaiChoice {
|
|
23
|
+
message: {
|
|
24
|
+
content?: string | null;
|
|
25
|
+
tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[];
|
|
26
|
+
};
|
|
27
|
+
finish_reason: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
let _counter = 0;
|
|
33
|
+
function hawkId(): string {
|
|
34
|
+
return 'hawk_' + (++_counter).toString(36) + '_' + Date.now().toString(36);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toOaiTools(tools: ProviderTool[]): OaiTool[] {
|
|
38
|
+
return tools.map(t => ({
|
|
39
|
+
type: 'function' as const,
|
|
40
|
+
function: {
|
|
41
|
+
name: t.name,
|
|
42
|
+
description: t.description,
|
|
43
|
+
parameters: t.input_schema,
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toOaiMessages(messages: ChatMessage[], system?: string): OaiMessage[] {
|
|
49
|
+
const out: OaiMessage[] = [];
|
|
50
|
+
|
|
51
|
+
if (system) out.push({ role: 'system', content: system });
|
|
52
|
+
|
|
53
|
+
for (const msg of messages) {
|
|
54
|
+
if (typeof msg.content === 'string') {
|
|
55
|
+
out.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const blocks = msg.content as ContentBlock[];
|
|
60
|
+
const textParts = blocks.filter(b => b.type === 'text').map(b => (b as { type: 'text'; text: string }).text);
|
|
61
|
+
const toolUses = blocks.filter(b => b.type === 'tool_use') as { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }[];
|
|
62
|
+
const toolResults = blocks.filter(b => b.type === 'tool_result') as { type: 'tool_result'; tool_use_id: string; content: string }[];
|
|
63
|
+
|
|
64
|
+
if (msg.role === 'assistant') {
|
|
65
|
+
const oai: OaiMessage = { role: 'assistant', content: textParts.join('\n') || null };
|
|
66
|
+
if (toolUses.length > 0) {
|
|
67
|
+
oai.tool_calls = toolUses.map(tu => ({
|
|
68
|
+
id: tu.id,
|
|
69
|
+
type: 'function' as const,
|
|
70
|
+
function: { name: tu.name, arguments: JSON.stringify(tu.input) },
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
out.push(oai);
|
|
74
|
+
} else {
|
|
75
|
+
// User turn — may contain tool_result blocks (sent back after assistant tool_use)
|
|
76
|
+
for (const tr of toolResults) {
|
|
77
|
+
out.push({ role: 'tool', tool_call_id: tr.tool_use_id, content: tr.content });
|
|
78
|
+
}
|
|
79
|
+
if (textParts.length > 0) {
|
|
80
|
+
out.push({ role: 'user', content: textParts.join('\n') });
|
|
81
|
+
}
|
|
82
|
+
// If only tool_results and no text, we've already pushed them
|
|
83
|
+
if (toolResults.length === 0 && textParts.length === 0) {
|
|
84
|
+
out.push({ role: 'user', content: '' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseArguments(raw: string): Record<string, unknown> {
|
|
92
|
+
try { return JSON.parse(raw); } catch { return { _raw: raw }; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Provider ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export class HawkProvider implements LLMProvider {
|
|
98
|
+
readonly name = 'hawk';
|
|
99
|
+
readonly model: string;
|
|
100
|
+
private proxyUrl: string;
|
|
101
|
+
|
|
102
|
+
constructor(options: HawkLLMProviderOptions) {
|
|
103
|
+
this.model = options.model;
|
|
104
|
+
this.proxyUrl = options.proxyUrl;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async chat(
|
|
108
|
+
messages: ChatMessage[],
|
|
109
|
+
tools: ProviderTool[],
|
|
110
|
+
options?: { signal?: AbortSignal; system?: string; maxTokens?: number; temperature?: number },
|
|
111
|
+
): Promise<LLMResponse> {
|
|
112
|
+
const oaiMessages = toOaiMessages(messages, options?.system);
|
|
113
|
+
const oaiTools = tools.length > 0 ? toOaiTools(tools) : undefined;
|
|
114
|
+
|
|
115
|
+
// NOTE: `model` is NOT sent in the body — the server proxy injects it
|
|
116
|
+
// from the X-Model header into the upstream Hawk request.
|
|
117
|
+
const body: Record<string, unknown> = {
|
|
118
|
+
messages: oaiMessages,
|
|
119
|
+
stream: false,
|
|
120
|
+
};
|
|
121
|
+
if (oaiTools) body.tools = oaiTools;
|
|
122
|
+
if (options?.maxTokens) body.max_tokens = options.maxTokens;
|
|
123
|
+
if (options?.temperature != null) body.temperature = options.temperature;
|
|
124
|
+
|
|
125
|
+
const response = await fetch(this.proxyUrl, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Content-Type': 'application/json',
|
|
129
|
+
'X-Model': this.model,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal: options?.signal,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const txt = await response.text().catch(() => '');
|
|
137
|
+
throw new Error(`Hawk LLM ${response.status}${txt ? ': ' + txt.slice(0, 200) : ''}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const data = await response.json() as { choices?: OaiChoice[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
|
|
141
|
+
const choice = data.choices?.[0];
|
|
142
|
+
if (!choice) throw new Error('Hawk LLM returned no choices');
|
|
143
|
+
|
|
144
|
+
const content: ContentBlock[] = [];
|
|
145
|
+
const toolCalls = choice.message.tool_calls;
|
|
146
|
+
|
|
147
|
+
if (choice.message.content) {
|
|
148
|
+
content.push({ type: 'text', text: choice.message.content });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
152
|
+
for (const tc of toolCalls) {
|
|
153
|
+
content.push({
|
|
154
|
+
type: 'tool_use',
|
|
155
|
+
id: tc.id || hawkId(),
|
|
156
|
+
name: tc.function.name,
|
|
157
|
+
input: parseArguments(tc.function.arguments),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Ensure at least one block
|
|
163
|
+
if (content.length === 0) {
|
|
164
|
+
content.push({ type: 'text', text: '' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const hasToolUse = content.some(b => b.type === 'tool_use');
|
|
168
|
+
const stopReason = hasToolUse ? 'tool_use'
|
|
169
|
+
: choice.finish_reason === 'tool_calls' ? 'tool_use'
|
|
170
|
+
: 'end_turn';
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content,
|
|
174
|
+
stopReason,
|
|
175
|
+
usage: data.usage ? {
|
|
176
|
+
input_tokens: data.usage.prompt_tokens ?? 0,
|
|
177
|
+
output_tokens: data.usage.completion_tokens ?? 0,
|
|
178
|
+
} : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -114,28 +114,13 @@ async function parseToolCalls(
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
// --------------------------------------------------------------------------
|
|
117
|
-
//
|
|
118
|
-
//
|
|
117
|
+
// Cache note: transformers.js manages its own cache via Cache Storage API
|
|
118
|
+
// (enabled by `env.useBrowserCache = true` below). No OPFS pre-download from
|
|
119
|
+
// this worker — the generic OPFS helper requires an explicit file list that
|
|
120
|
+
// transformers.js doesn't expose. Progress is surfaced via `progress_callback`
|
|
121
|
+
// in `fromPretrainedOpts`.
|
|
119
122
|
// --------------------------------------------------------------------------
|
|
120
123
|
|
|
121
|
-
async function loadOrDownloadModel(
|
|
122
|
-
_repo: string,
|
|
123
|
-
_onProgress: (fileProgress: number, totalProgress: number, status: string, loaded?: number, total?: number) => void,
|
|
124
|
-
): Promise<void> {
|
|
125
|
-
try {
|
|
126
|
-
// Optional fallback import — module is shipped (../util/opfs-cache.ts);
|
|
127
|
-
// the try/catch is defensive only, guarding against bundler quirks or
|
|
128
|
-
// OPFS being unavailable in the worker (older browsers).
|
|
129
|
-
const mod: any = await import('../util/opfs-cache.js');
|
|
130
|
-
const fn = mod.loadOrDownloadModel ?? mod.default;
|
|
131
|
-
if (typeof fn === 'function') return await fn(_repo, _onProgress);
|
|
132
|
-
} catch {
|
|
133
|
-
// Import/OPFS unavailable — transformers.js falls back to its internal
|
|
134
|
-
// HTTP fetch + `caches` API. Progress arrives via from_pretrained's
|
|
135
|
-
// progress_callback below.
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
124
|
// --------------------------------------------------------------------------
|
|
140
125
|
// Helpers
|
|
141
126
|
// --------------------------------------------------------------------------
|
|
@@ -229,18 +214,6 @@ async function loadModel(modelEntry: TransformersModelEntry): Promise<void> {
|
|
|
229
214
|
|
|
230
215
|
stoppingCriteria = new InterruptableStoppingCriteria();
|
|
231
216
|
|
|
232
|
-
// Pre-download (OPFS-aware when the cache module is available).
|
|
233
|
-
await loadOrDownloadModel(modelEntry.repo, (fp, tp, status, loaded, total) => {
|
|
234
|
-
post({
|
|
235
|
-
type: 'progress',
|
|
236
|
-
fileProgress: fp,
|
|
237
|
-
totalProgress: tp,
|
|
238
|
-
status,
|
|
239
|
-
loaded: loaded ?? 0,
|
|
240
|
-
total: total ?? modelEntry.size,
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
|
|
244
217
|
// Aggregated progress callback — sums loaded/total across every file we see,
|
|
245
218
|
// emitting a monotonic aggregate ratio. Two guards eliminate flicker:
|
|
246
219
|
// 1. Files with total < 1_000_000 bytes are ignored (configs, tokenizers,
|