@webmcp-auto-ui/agent 2.5.27 → 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 +63 -75
- package/src/index.ts +7 -2
- package/src/loop.ts +48 -21
- package/src/providers/factory.ts +15 -1
- package/src/providers/hawk-models.ts +22 -0
- package/src/providers/hawk.ts +181 -0
- package/src/providers/transformers.worker.ts +5 -32
- package/src/recipes/_generated.ts +81 -17
- package/src/recipes/notebook-playbook.md +81 -17
- package/src/server/hawkProxy.ts +54 -0
- package/src/server/index.ts +2 -0
- 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.28",
|
|
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,64 @@
|
|
|
5
5
|
import { createWebMcpServer, parseFrontmatter } from '@webmcp-auto-ui/core';
|
|
6
6
|
import { RAW_RECIPES } from './recipes/_generated.js';
|
|
7
7
|
|
|
8
|
-
// Notebook widget recipes (vanilla renderers)
|
|
8
|
+
// Notebook widget recipes (vanilla renderers) — moved to @webmcp-auto-ui/ui
|
|
9
9
|
// @ts-ignore — Vite raw imports, not resolved by tsc
|
|
10
|
-
import compactRecipe from '
|
|
10
|
+
import compactRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/compact.md?raw';
|
|
11
11
|
// @ts-ignore
|
|
12
|
-
import workspaceRecipe from '
|
|
12
|
+
import workspaceRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/workspace.md?raw';
|
|
13
13
|
// @ts-ignore
|
|
14
|
-
import documentRecipe from '
|
|
14
|
+
import documentRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/document.md?raw';
|
|
15
15
|
// @ts-ignore
|
|
16
|
-
import editorialRecipe from '
|
|
16
|
+
import editorialRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/editorial.md?raw';
|
|
17
|
+
|
|
18
|
+
// Notebook widget renderers (vanilla JS) — import via subpath to avoid pulling
|
|
19
|
+
// the .svelte exports of the ui package root through tsc.
|
|
20
|
+
import { render as renderCompact } from '@webmcp-auto-ui/ui/widgets/notebook/compact.js';
|
|
21
|
+
import { render as renderWorkspace } from '@webmcp-auto-ui/ui/widgets/notebook/workspace.js';
|
|
22
|
+
import { render as renderDocument } from '@webmcp-auto-ui/ui/widgets/notebook/document.js';
|
|
23
|
+
import { render as renderEditorial } from '@webmcp-auto-ui/ui/widgets/notebook/editorial.js';
|
|
24
|
+
import { render as renderRecipeBrowser } from '@webmcp-auto-ui/ui/widgets/notebook/recipe-browser.js';
|
|
25
|
+
|
|
26
|
+
// Inline recipe for recipe-browser (real vanilla widget)
|
|
27
|
+
const recipeBrowserRecipe = `---
|
|
28
|
+
widget: recipe-browser
|
|
29
|
+
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.
|
|
30
|
+
group: rich
|
|
31
|
+
schema:
|
|
32
|
+
type: object
|
|
33
|
+
required:
|
|
34
|
+
- recipes
|
|
35
|
+
properties:
|
|
36
|
+
recipes:
|
|
37
|
+
type: array
|
|
38
|
+
description: List of Recipe objects (id, name, description, body, servers, ...).
|
|
39
|
+
items:
|
|
40
|
+
type: object
|
|
41
|
+
filters:
|
|
42
|
+
type: object
|
|
43
|
+
description: Initial filters
|
|
44
|
+
properties:
|
|
45
|
+
q:
|
|
46
|
+
type: string
|
|
47
|
+
kind:
|
|
48
|
+
type: string
|
|
49
|
+
enum: [all, webmcp, mcp]
|
|
50
|
+
tags:
|
|
51
|
+
type: array
|
|
52
|
+
items:
|
|
53
|
+
type: string
|
|
54
|
+
layout:
|
|
55
|
+
type: string
|
|
56
|
+
enum: [list, grid]
|
|
57
|
+
description: Default layout (default list)
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## When to use
|
|
61
|
+
When the user wants to browse, search, or pick a recipe — for example "show me the available recipes" or "let me choose a recipe".
|
|
17
62
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import { render as renderDocument } from './notebook-widgets/document.js';
|
|
22
|
-
import { render as renderEditorial } from './notebook-widgets/editorial.js';
|
|
63
|
+
## How to use
|
|
64
|
+
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.
|
|
65
|
+
`;
|
|
23
66
|
|
|
24
67
|
// ---------------------------------------------------------------------------
|
|
25
68
|
// Inline recipes (frontmatter + body)
|
|
@@ -936,77 +979,21 @@ Pour des visualisations custom, animations, ou prototypes interactifs en JS pur.
|
|
|
936
979
|
Call widget_display({name: "js-sandbox", params: {code: "document.getElementById('root').innerHTML = '<h1>Hello</h1>'"}}).
|
|
937
980
|
`,
|
|
938
981
|
|
|
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
982
|
];
|
|
1003
983
|
|
|
1004
984
|
// ---------------------------------------------------------------------------
|
|
1005
985
|
// Native widget names — derived from RECIPES frontmatter
|
|
1006
986
|
// ---------------------------------------------------------------------------
|
|
1007
987
|
|
|
1008
|
-
/** Derived from RECIPES
|
|
1009
|
-
|
|
988
|
+
/** Derived from RECIPES + notebook widget recipes — always in sync with registered widgets */
|
|
989
|
+
const _NOTEBOOK_RECIPE_SOURCES: string[] = [
|
|
990
|
+
compactRecipe as string,
|
|
991
|
+
workspaceRecipe as string,
|
|
992
|
+
documentRecipe as string,
|
|
993
|
+
editorialRecipe as string,
|
|
994
|
+
recipeBrowserRecipe,
|
|
995
|
+
];
|
|
996
|
+
export const NATIVE_WIDGET_NAMES = [...RECIPES, ..._NOTEBOOK_RECIPE_SOURCES].map(r => {
|
|
1010
997
|
const match = r.match(/widget:\s*(\S+)/);
|
|
1011
998
|
return match ? match[1] : '';
|
|
1012
999
|
}).filter(Boolean) as string[];
|
|
@@ -1030,6 +1017,7 @@ const NOTEBOOK_WIDGETS: Array<[string, (container: HTMLElement, data: any) => an
|
|
|
1030
1017
|
[workspaceRecipe as string, renderWorkspace],
|
|
1031
1018
|
[documentRecipe as string, renderDocument],
|
|
1032
1019
|
[editorialRecipe as string, renderEditorial],
|
|
1020
|
+
[recipeBrowserRecipe, renderRecipeBrowser],
|
|
1033
1021
|
];
|
|
1034
1022
|
for (const [recipe, renderer] of NOTEBOOK_WIDGETS) {
|
|
1035
1023
|
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
|
@@ -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
|
+
// Snapshot pathMaps locally (parallel-safe). Reading the global flattenPathMaps
|
|
172
|
+
// singleton at dispatch-time races when two loops run concurrently.
|
|
173
|
+
const localPathMaps = new Map<string, Record<string, string[]>>(flattenPathMaps);
|
|
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
|
|
|
@@ -401,8 +411,11 @@ export async function runAgentLoop(
|
|
|
401
411
|
const protocol = tokenToProtocol(token);
|
|
402
412
|
|
|
403
413
|
// Auto-repair + validate params before dispatch
|
|
414
|
+
// Resolve from the full activeTools (not iterationTools, which may be filtered
|
|
415
|
+
// to strip discovery tools after 4 iterations — would make toolDef undefined
|
|
416
|
+
// and silently skip auto-repair + schema validation).
|
|
404
417
|
let toolInput = block.input as Record<string, unknown>;
|
|
405
|
-
const toolDef =
|
|
418
|
+
const toolDef = activeTools.find(t => t.name === block.name);
|
|
406
419
|
if (toolDef?.input_schema) {
|
|
407
420
|
const repair = autoRepairParams(toolInput, toolDef.input_schema, realToolName);
|
|
408
421
|
if (repair.fixes.length > 0) {
|
|
@@ -451,8 +464,9 @@ export async function runAgentLoop(
|
|
|
451
464
|
result = `Error: no WebMCP server "${serverName}" found.`;
|
|
452
465
|
} else {
|
|
453
466
|
// Unflatten params if schema was flattened
|
|
467
|
+
// Use the local snapshot (parallel-safe) rather than the global singleton.
|
|
454
468
|
if (schemaOptions?.flatten) {
|
|
455
|
-
const pathMap =
|
|
469
|
+
const pathMap = localPathMaps.get(block.name);
|
|
456
470
|
if (pathMap) {
|
|
457
471
|
toolInput = unflattenParams(toolInput, pathMap);
|
|
458
472
|
}
|
|
@@ -638,18 +652,31 @@ export function trimConversationHistory(history: ChatMessage[], maxTokens: numbe
|
|
|
638
652
|
total -= removed.reduce((s, m) => s + JSON.stringify(m).length, 0);
|
|
639
653
|
}
|
|
640
654
|
|
|
641
|
-
// Remove orphaned tool_result
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
655
|
+
// Remove orphaned tool_result blocks anywhere in history — strict providers
|
|
656
|
+
// (Anthropic, etc.) reject tool_result blocks whose tool_use_id does not
|
|
657
|
+
// correspond to an earlier assistant tool_use. Head-only pruning misses
|
|
658
|
+
// internal orphans caused by mid-history trims.
|
|
659
|
+
const validToolUseIds = new Set<string>();
|
|
660
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
661
|
+
const msg = trimmed[i];
|
|
662
|
+
// Collect tool_use ids from assistant messages seen so far
|
|
663
|
+
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
664
|
+
for (const b of msg.content as any[]) {
|
|
665
|
+
if (b?.type === 'tool_use' && typeof b.id === 'string') validToolUseIds.add(b.id);
|
|
666
|
+
}
|
|
652
667
|
}
|
|
668
|
+
// Filter out orphan tool_result blocks in user messages
|
|
669
|
+
if (msg.role === 'user' && Array.isArray(msg.content)) {
|
|
670
|
+
msg.content = (msg.content as any[]).filter(b => {
|
|
671
|
+
if (b?.type !== 'tool_result') return true;
|
|
672
|
+
return typeof b.tool_use_id === 'string' && validToolUseIds.has(b.tool_use_id);
|
|
673
|
+
}) as any;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Drop user messages that became empty after orphan-pruning
|
|
677
|
+
for (let i = trimmed.length - 1; i >= 0; i--) {
|
|
678
|
+
const c = trimmed[i].content;
|
|
679
|
+
if (Array.isArray(c) && c.length === 0) trimmed.splice(i, 1);
|
|
653
680
|
}
|
|
654
681
|
|
|
655
682
|
// 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,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
|
+
}
|
|
@@ -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,
|