@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/agent",
3
- "version": "2.5.27",
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/llmProxy.ts"
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
  }
@@ -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 './notebook-widgets/recipes/compact.md?raw';
10
+ import compactRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/compact.md?raw';
11
11
  // @ts-ignore
12
- import workspaceRecipe from './notebook-widgets/recipes/workspace.md?raw';
12
+ import workspaceRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/workspace.md?raw';
13
13
  // @ts-ignore
14
- import documentRecipe from './notebook-widgets/recipes/document.md?raw';
14
+ import documentRecipe from '@webmcp-auto-ui/ui/widgets/notebook/recipes/document.md?raw';
15
15
  // @ts-ignore
16
- import editorialRecipe from './notebook-widgets/recipes/editorial.md?raw';
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
- // Notebook widget renderers (vanilla JS)
19
- import { render as renderCompact } from './notebook-widgets/compact.js';
20
- import { render as renderWorkspace } from './notebook-widgets/workspace.js';
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 frontmatter — always in sync with registered widgets */
1009
- export const NATIVE_WIDGET_NAMES = RECIPES.map(r => {
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 (lastMsg && lastMsg.role === 'user') {
235
- if (typeof lastMsg.content === 'string') {
236
- lastMsg.content = [{ type: 'text', text: lastMsg.content }, { type: 'text', text: nudgeText }];
237
- } else if (Array.isArray(lastMsg.content)) {
238
- (lastMsg.content as ContentBlock[]).push({ type: 'text', text: nudgeText });
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 = iterationTools.find(t => t.name === block.name);
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 = flattenPathMaps.get(block.name);
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 messages at the startthese reference
642
- // a tool_use in a message that was trimmed away, causing API errors.
643
- while (trimmed.length > 0) {
644
- const first = trimmed[0];
645
- if (first.role === 'system') break; // preserve system messages at the front
646
- const blocks = Array.isArray(first.content) ? first.content : [];
647
- const hasToolResult = blocks.some((b: any) => b.type === 'tool_result');
648
- if (hasToolResult) {
649
- trimmed.shift();
650
- } else {
651
- break;
655
+ // Remove orphaned tool_result blocks anywhere in historystrict 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)
@@ -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
- // OPFS cache loaded lazily with a best-effort fallback that defers entirely
118
- // to transformers.js's built-in HF cache (no OPFS intervention on our side).
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,