@webmcp-auto-ui/agent 2.5.25 → 2.5.27

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.
Files changed (73) hide show
  1. package/package.json +1 -1
  2. package/src/autoui-server.ts +44 -0
  3. package/src/diagnostics.ts +6 -6
  4. package/src/discovery-cache.ts +17 -3
  5. package/src/index.ts +18 -4
  6. package/src/loop.ts +31 -34
  7. package/src/notebook-widgets/compact.ts +312 -0
  8. package/src/notebook-widgets/document.ts +372 -0
  9. package/src/notebook-widgets/editorial.ts +348 -0
  10. package/src/notebook-widgets/recipes/compact.md +104 -0
  11. package/src/notebook-widgets/recipes/document.md +100 -0
  12. package/src/notebook-widgets/recipes/editorial.md +104 -0
  13. package/src/notebook-widgets/recipes/workspace.md +94 -0
  14. package/src/notebook-widgets/shared.ts +1064 -0
  15. package/src/notebook-widgets/workspace.ts +328 -0
  16. package/src/prompts/claude-prompt-builder.ts +81 -0
  17. package/src/prompts/gemma4-prompt-builder.ts +205 -0
  18. package/src/prompts/index.ts +55 -0
  19. package/src/prompts/mistral-prompt-builder.ts +90 -0
  20. package/src/prompts/qwen-prompt-builder.ts +90 -0
  21. package/src/prompts/tool-call-parsers.ts +322 -0
  22. package/src/prompts/tool-refs.ts +196 -0
  23. package/src/providers/factory.ts +20 -3
  24. package/src/providers/transformers-models.ts +143 -0
  25. package/src/providers/transformers-serialize.ts +81 -0
  26. package/src/providers/transformers.ts +329 -0
  27. package/src/providers/transformers.worker.ts +667 -0
  28. package/src/providers/wasm.ts +150 -510
  29. package/src/recipes/_generated.ts +515 -0
  30. package/src/recipes/canary-data.md +50 -0
  31. package/src/recipes/canary-display.md +99 -0
  32. package/src/recipes/canary-middle.md +32 -0
  33. package/src/recipes/hackathon-assemblee-nationale.md +111 -0
  34. package/src/recipes/hummingbird-data.md +32 -0
  35. package/src/recipes/hummingbird-display.md +36 -0
  36. package/src/recipes/hummingbird-middle.md +18 -0
  37. package/src/recipes/notebook-playbook.md +129 -0
  38. package/src/tool-layers.ts +33 -157
  39. package/src/trace-observer.ts +669 -0
  40. package/src/types.ts +20 -5
  41. package/src/util/opfs-cache.ts +265 -0
  42. package/tests/gemma-prompt.test.ts +472 -0
  43. package/tests/loop.test.ts +5 -5
  44. package/tests/transformers-serialize.test.ts +103 -0
  45. package/src/providers/gemma.worker.legacy.ts +0 -123
  46. package/src/providers/litert.worker.ts +0 -294
  47. package/src/recipes/widgets/actions.md +0 -28
  48. package/src/recipes/widgets/alert.md +0 -27
  49. package/src/recipes/widgets/cards.md +0 -41
  50. package/src/recipes/widgets/carousel.md +0 -39
  51. package/src/recipes/widgets/chart-rich.md +0 -51
  52. package/src/recipes/widgets/chart.md +0 -32
  53. package/src/recipes/widgets/code.md +0 -21
  54. package/src/recipes/widgets/d3.md +0 -36
  55. package/src/recipes/widgets/data-table.md +0 -46
  56. package/src/recipes/widgets/gallery.md +0 -39
  57. package/src/recipes/widgets/grid-data.md +0 -57
  58. package/src/recipes/widgets/hemicycle.md +0 -43
  59. package/src/recipes/widgets/js-sandbox.md +0 -32
  60. package/src/recipes/widgets/json-viewer.md +0 -27
  61. package/src/recipes/widgets/kv.md +0 -31
  62. package/src/recipes/widgets/list.md +0 -24
  63. package/src/recipes/widgets/log.md +0 -39
  64. package/src/recipes/widgets/map.md +0 -49
  65. package/src/recipes/widgets/profile.md +0 -49
  66. package/src/recipes/widgets/recipe-browser.md +0 -102
  67. package/src/recipes/widgets/sankey.md +0 -54
  68. package/src/recipes/widgets/stat-card.md +0 -43
  69. package/src/recipes/widgets/stat.md +0 -35
  70. package/src/recipes/widgets/tags.md +0 -30
  71. package/src/recipes/widgets/text.md +0 -19
  72. package/src/recipes/widgets/timeline.md +0 -38
  73. package/src/recipes/widgets/trombinoscope.md +0 -39
@@ -0,0 +1,196 @@
1
+ // Collect per-layer prompt references (tool names formatted for the LLM).
2
+
3
+ import type { ProviderTool } from '../types.js';
4
+ import {
5
+ buildToolsFromLayers,
6
+ protocolToken,
7
+ resolveCanonicalTools,
8
+ sanitizeServerName,
9
+ type McpLayer,
10
+ type ProviderKind,
11
+ type ToolLayer,
12
+ type WebMcpLayer,
13
+ } from '../tool-layers.js';
14
+ import { formatGemmaToolDeclaration } from './gemma4-prompt-builder.js';
15
+
16
+ /** Short descriptions for discovery pseudo-tools (used in inline Gemma decls). */
17
+ const shortSearchToolsDesc = (serverName: string) =>
18
+ `Search tools by keyword on the ${serverName} server.`;
19
+ const shortListToolsDesc = (serverName: string) =>
20
+ `List ALL tools on the ${serverName} server.`;
21
+
22
+ /** First parameter name from a tool's input schema (for arg hints). */
23
+ function firstParamName(tool?: ProviderTool, fallback = 'query'): string {
24
+ if (!tool?.input_schema) return fallback;
25
+ const schema = tool.input_schema as Record<string, unknown>;
26
+ const required = (schema.required as string[] | undefined) ?? [];
27
+ if (required.length > 0) return required[0];
28
+ const props = (schema.properties as Record<string, unknown> | undefined) ?? {};
29
+ return Object.keys(props)[0] ?? fallback;
30
+ }
31
+
32
+ /** Format a tool reference for the system prompt (generic or inline Gemma decl). */
33
+ function fmtToolRef(
34
+ prefixedName: string,
35
+ args: string[] = [],
36
+ kind: ProviderKind = 'generic',
37
+ tool?: ProviderTool,
38
+ ): string {
39
+ if (kind === 'gemma' && tool) {
40
+ return formatGemmaToolDeclaration({ ...tool, name: prefixedName });
41
+ }
42
+ if (kind === 'gemma' || kind === 'qwen' || kind === 'mistral') {
43
+ return args.length ? `\`${prefixedName}(${args.join(', ')})\`` : `\`${prefixedName}\``;
44
+ }
45
+ return args.length ? `${prefixedName}(${args.join(', ')})` : `${prefixedName}()`;
46
+ }
47
+
48
+ export interface PromptRefs {
49
+ searchRecipes: string[];
50
+ listRecipes: string[];
51
+ getRecipes: string[];
52
+ searchTools: string[];
53
+ listTools: string[];
54
+ actionTools: string[];
55
+ listRecipesByCat: { data: string[]; display: string[] };
56
+ searchRecipesByCat: { data: string[]; display: string[] };
57
+ getRecipesByCat: { data: string[]; display: string[] };
58
+ aliasMap: Map<string, string>;
59
+ }
60
+
61
+ export function collectPromptRefs(
62
+ layers: ToolLayer[],
63
+ providerKind: ProviderKind,
64
+ ): PromptRefs {
65
+ const kind = providerKind;
66
+ const mcpLayers = layers.filter((l): l is McpLayer => l.protocol === 'mcp');
67
+ const webmcpLayers = layers.filter((l): l is WebMcpLayer => l.protocol === 'webmcp');
68
+
69
+ const displayLayers = webmcpLayers.filter(l => l.tools.some(t => t.name === 'widget_display'));
70
+ const dataLayers = layers.filter(l => !displayLayers.includes(l as WebMcpLayer));
71
+
72
+ const providerToolsByName = new Map<string, ProviderTool>(
73
+ buildToolsFromLayers(layers, { sanitize: true }).tools.map(t => [t.name, t]),
74
+ );
75
+
76
+ const aliasMap = new Map<string, string>();
77
+ const searchRecipes: string[] = [];
78
+ const listRecipes: string[] = [];
79
+ const getRecipes: string[] = [];
80
+ const searchTools: string[] = [];
81
+ const listTools: string[] = [];
82
+
83
+ for (const l of webmcpLayers) {
84
+ const prefix = `${sanitizeServerName(l.serverName)}_ui_`;
85
+ for (const t of l.tools) {
86
+ if (t.name === 'search_recipes') {
87
+ const name = `${prefix}search_recipes`;
88
+ const toolDef = providerToolsByName.get(name);
89
+ searchRecipes.push(fmtToolRef(name, [firstParamName(toolDef, 'query')], kind, toolDef));
90
+ }
91
+ if (t.name === 'list_recipes') {
92
+ const name = `${prefix}list_recipes`;
93
+ const toolDef = providerToolsByName.get(name);
94
+ listRecipes.push(fmtToolRef(name, [], kind, toolDef));
95
+ }
96
+ if (t.name === 'get_recipe') {
97
+ const name = `${prefix}get_recipe`;
98
+ const toolDef = providerToolsByName.get(name);
99
+ getRecipes.push(fmtToolRef(name, [firstParamName(toolDef, 'id')], kind, toolDef));
100
+ }
101
+ }
102
+ const searchToolsPseudo: ProviderTool = {
103
+ name: `${prefix}search_tools`,
104
+ description: shortSearchToolsDesc(l.serverName),
105
+ input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for.' } }, required: ['query'] },
106
+ };
107
+ searchTools.push(fmtToolRef(`${prefix}search_tools`, ['query'], kind, searchToolsPseudo));
108
+ const listToolsPseudo: ProviderTool = {
109
+ name: `${prefix}list_tools`,
110
+ description: shortListToolsDesc(l.serverName),
111
+ input_schema: { type: 'object', properties: {} },
112
+ };
113
+ listTools.push(fmtToolRef(`${prefix}list_tools`, [], kind, listToolsPseudo));
114
+ }
115
+
116
+ for (const l of mcpLayers) {
117
+ const prefix = `${sanitizeServerName(l.serverName)}_data_`;
118
+ const matches = resolveCanonicalTools(l.tools);
119
+
120
+ for (const m of matches) {
121
+ const canonicalPrefixed = `${prefix}${m.role}`;
122
+ const realPrefixed = `${prefix}${m.realToolName}`;
123
+
124
+ if (m.role !== m.realToolName) {
125
+ aliasMap.set(canonicalPrefixed, realPrefixed);
126
+ }
127
+
128
+ const realToolDef = providerToolsByName.get(realPrefixed);
129
+
130
+ if (m.role === 'search_recipes') {
131
+ searchRecipes.push(fmtToolRef(canonicalPrefixed, [firstParamName(realToolDef, 'query')], kind, realToolDef));
132
+ }
133
+ if (m.role === 'list_recipes') {
134
+ listRecipes.push(fmtToolRef(canonicalPrefixed, [], kind, realToolDef));
135
+ }
136
+ if (m.role === 'get_recipe') {
137
+ getRecipes.push(fmtToolRef(canonicalPrefixed, [firstParamName(realToolDef, 'id')], kind, realToolDef));
138
+ }
139
+ }
140
+
141
+ const searchToolsPseudo: ProviderTool = {
142
+ name: `${prefix}search_tools`,
143
+ description: shortSearchToolsDesc(l.serverName),
144
+ input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for.' } }, required: ['query'] },
145
+ };
146
+ searchTools.push(fmtToolRef(`${prefix}search_tools`, ['query'], kind, searchToolsPseudo));
147
+ const listToolsPseudo: ProviderTool = {
148
+ name: `${prefix}list_tools`,
149
+ description: shortListToolsDesc(l.serverName),
150
+ input_schema: { type: 'object', properties: {} },
151
+ };
152
+ listTools.push(fmtToolRef(`${prefix}list_tools`, [], kind, listToolsPseudo));
153
+ }
154
+
155
+ const actionTools: string[] = [];
156
+ const ACTION_NAMES = ['widget_display', 'canvas', 'recall'];
157
+ for (const l of webmcpLayers) {
158
+ const prefix = `${sanitizeServerName(l.serverName)}_ui_`;
159
+ for (const actionName of ACTION_NAMES) {
160
+ if (l.tools.some(t => t.name === actionName)) {
161
+ const prefixedName = `${prefix}${actionName}`;
162
+ const toolDef = providerToolsByName.get(prefixedName);
163
+ const args = actionName === 'widget_display' ? ['name', 'params'] : [];
164
+ actionTools.push(fmtToolRef(prefixedName, args, kind, toolDef));
165
+ }
166
+ }
167
+ }
168
+
169
+ const dataPrefixes = new Set(dataLayers.map(l => `${sanitizeServerName(l.serverName)}_${protocolToken(l.protocol)}_`));
170
+ const displayPrefixes = new Set(displayLayers.map(l => `${sanitizeServerName(l.serverName)}_ui_`));
171
+ void dataPrefixes;
172
+
173
+ function splitByCategory(refs: string[]): { data: string[]; display: string[] } {
174
+ const data: string[] = [];
175
+ const display: string[] = [];
176
+ for (const ref of refs) {
177
+ const isDisplay = [...displayPrefixes].some(p => ref.includes(p));
178
+ if (isDisplay) display.push(ref);
179
+ else data.push(ref);
180
+ }
181
+ return { data, display };
182
+ }
183
+
184
+ return {
185
+ searchRecipes,
186
+ listRecipes,
187
+ getRecipes,
188
+ searchTools,
189
+ listTools,
190
+ actionTools,
191
+ listRecipesByCat: splitByCategory(listRecipes),
192
+ searchRecipesByCat: splitByCategory(searchRecipes),
193
+ getRecipesByCat: splitByCategory(getRecipes),
194
+ aliasMap,
195
+ };
196
+ }
@@ -2,15 +2,27 @@ import type { LLMProvider, RemoteModelId, WasmModelId } from '../types.js';
2
2
  import { RemoteLLMProvider } from './remote.js';
3
3
  import { WasmProvider } from './wasm.js';
4
4
  import { LocalLLMProvider, type LocalBackend } from './local.js';
5
+ import { TransformersProvider } from './transformers.js';
5
6
 
6
7
  export type LLMConfig =
7
- | { type: 'remote'; model?: RemoteModelId; proxyUrl?: string; apiKey?: string }
8
- | { type: 'wasm'; model?: WasmModelId; onProgress?: (loaded: number, total: number) => void }
9
- | { type: 'local'; model: string; baseUrl: string; backend?: LocalBackend };
8
+ | { type: 'remote'; model?: RemoteModelId; proxyUrl?: string; apiKey?: string }
9
+ | { type: 'wasm'; model?: WasmModelId; onProgress?: (loaded: number, total: number) => void }
10
+ | { type: 'transformers'; model: string; onProgress?: (loaded: number, total: number) => void }
11
+ | { type: 'local'; model: string; baseUrl: string; backend?: LocalBackend };
10
12
 
11
13
  export function createProvider(config: LLMConfig): LLMProvider {
12
14
  const base = typeof window !== 'undefined' ? (document.querySelector('base') as HTMLBaseElement | null)?.href ?? '' : '';
13
15
 
16
+ // Prefix-based dispatch: a `transformers-*` model routes to TransformersProvider
17
+ // regardless of the declared type (defensive).
18
+ if ('model' in config && typeof config.model === 'string' && config.model.startsWith('transformers-')) {
19
+ const onProgress = (config as { onProgress?: (loaded: number, total: number) => void }).onProgress;
20
+ return new TransformersProvider({
21
+ model: config.model,
22
+ onProgress: onProgress ? (_progress, _status, loaded, total) => onProgress(loaded ?? 0, total ?? 0) : undefined,
23
+ });
24
+ }
25
+
14
26
  switch (config.type) {
15
27
  case 'remote':
16
28
  return new RemoteLLMProvider({
@@ -23,6 +35,11 @@ export function createProvider(config: LLMConfig): LLMProvider {
23
35
  model: config.model,
24
36
  onProgress: config.onProgress ? (progress, _status, loaded, total) => config.onProgress!(loaded ?? 0, total ?? 0) : undefined,
25
37
  });
38
+ case 'transformers':
39
+ return new TransformersProvider({
40
+ model: config.model,
41
+ onProgress: config.onProgress ? (_progress, _status, loaded, total) => config.onProgress!(loaded ?? 0, total ?? 0) : undefined,
42
+ });
26
43
  case 'local':
27
44
  return new LocalLLMProvider({
28
45
  baseUrl: config.baseUrl,
@@ -0,0 +1,143 @@
1
+ // Shared catalog of in-browser models served via transformers.js (ONNX + WebGPU).
2
+ // Read by TransformersProvider (agent A) and the <LLMSelector> UI (agent D).
3
+ //
4
+ // Each entry pins:
5
+ // - `repo`: HuggingFace repository ID to load via from_pretrained
6
+ // - `dtype`: mixed per-component quantization (embed_tokens / decoder / vision)
7
+ // - `family`: prompt builder family — drives which {gemma,qwen,mistral}-prompt-builder is used
8
+ // - `toolFormat`: output tool-call syntax — drives which parser strategy is used
9
+ // - `contextLength`: native context budget (for clipping heuristics)
10
+ // - `vision`: true if the model accepts images via RawImage + AutoProcessor
11
+ // - `modelClass`: optional specialized transformers.js class name (for VLMs);
12
+ // defaults to AutoModelForCausalLM when omitted
13
+
14
+ export type TransformersFamily = 'gemma4' | 'qwen3' | 'mistral';
15
+ export type ToolCallFormat = 'gemma-native' | 'qwen-json' | 'mistral-toolcalls';
16
+
17
+ export type DType = 'q4' | 'q4f16' | 'q8' | 'fp16' | 'fp32';
18
+
19
+ export interface TransformersModelEntry {
20
+ repo: string;
21
+ dtype: DType | {
22
+ embed_tokens?: DType;
23
+ decoder_model_merged?: DType;
24
+ vision_encoder?: DType;
25
+ audio_encoder?: DType;
26
+ };
27
+ family: TransformersFamily;
28
+ toolFormat: ToolCallFormat;
29
+ contextLength: number;
30
+ vision: boolean;
31
+ modelClass?: string;
32
+ /** Approximate download size in bytes (for progress UI). */
33
+ size: number;
34
+ /** Human-readable label for the model selector. */
35
+ label: string;
36
+ }
37
+
38
+ export type TransformersModelId =
39
+ | 'transformers-gemma-4-e2b'
40
+ | 'transformers-gemma-4-e4b'
41
+ | 'transformers-qwen-3-4b'
42
+ | 'transformers-qwen-3.5-2b'
43
+ | 'transformers-qwen-3.5-4b'
44
+ | 'transformers-ministral-3-3b';
45
+
46
+ export const TRANSFORMERS_MODELS: Record<TransformersModelId, TransformersModelEntry> = {
47
+ 'transformers-gemma-4-e2b': {
48
+ repo: 'onnx-community/gemma-4-E2B-it-ONNX',
49
+ modelClass: 'Gemma4ForConditionalGeneration',
50
+ dtype: {
51
+ audio_encoder: 'q4',
52
+ vision_encoder: 'q4',
53
+ embed_tokens: 'q4',
54
+ decoder_model_merged: 'q4f16',
55
+ },
56
+ family: 'gemma4',
57
+ toolFormat: 'gemma-native',
58
+ contextLength: 32768,
59
+ vision: true,
60
+ size: 2_000_000_000,
61
+ label: 'Gemma 4 E2B (Vision)',
62
+ },
63
+ 'transformers-gemma-4-e4b': {
64
+ repo: 'onnx-community/gemma-4-E4B-it-ONNX',
65
+ modelClass: 'Gemma4ForConditionalGeneration',
66
+ dtype: {
67
+ audio_encoder: 'q4',
68
+ vision_encoder: 'q4',
69
+ embed_tokens: 'q4',
70
+ decoder_model_merged: 'q4f16',
71
+ },
72
+ family: 'gemma4',
73
+ toolFormat: 'gemma-native',
74
+ contextLength: 32768,
75
+ vision: true,
76
+ size: 3_000_000_000,
77
+ label: 'Gemma 4 E4B (Vision)',
78
+ },
79
+ 'transformers-qwen-3-4b': {
80
+ // onnx-community/Qwen3-4B-ONNX ships a monolithic model_q4f16.onnx
81
+ // (not split into embed_tokens + decoder_model_merged), so transformers.js
82
+ // expects a scalar dtype string to resolve onnx/model_<dtype>.onnx.
83
+ repo: 'onnx-community/Qwen3-4B-ONNX',
84
+ dtype: 'q4f16',
85
+ family: 'qwen3',
86
+ toolFormat: 'qwen-json',
87
+ contextLength: 32768,
88
+ vision: false,
89
+ size: 3_050_000_000,
90
+ label: 'Qwen 3 4B',
91
+ },
92
+ 'transformers-qwen-3.5-2b': {
93
+ repo: 'onnx-community/Qwen3.5-2B-ONNX',
94
+ dtype: { embed_tokens: 'q4', vision_encoder: 'fp16', decoder_model_merged: 'q4' },
95
+ family: 'qwen3',
96
+ toolFormat: 'qwen-json',
97
+ contextLength: 32768,
98
+ vision: true,
99
+ modelClass: 'Qwen3_5ForConditionalGeneration',
100
+ size: 1_600_000_000,
101
+ label: 'Qwen 3.5 2B',
102
+ },
103
+ 'transformers-qwen-3.5-4b': {
104
+ repo: 'onnx-community/Qwen3.5-4B-ONNX',
105
+ dtype: { embed_tokens: 'q4', vision_encoder: 'fp16', decoder_model_merged: 'q4' },
106
+ family: 'qwen3',
107
+ toolFormat: 'qwen-json',
108
+ contextLength: 32768,
109
+ vision: true,
110
+ modelClass: 'Qwen3_5ForConditionalGeneration',
111
+ size: 3_000_000_000,
112
+ label: 'Qwen 3.5 4B',
113
+ },
114
+ 'transformers-ministral-3-3b': {
115
+ repo: 'mistralai/Ministral-3-3B-Instruct-2512-ONNX',
116
+ // Mistral3ForConditionalGeneration is registered internally but not
117
+ // re-exported from transformers.js 4.1.0 — use the Auto wrapper, which
118
+ // routes via the registered name (this is what the official demo does).
119
+ modelClass: 'AutoModelForImageTextToText',
120
+ dtype: {
121
+ embed_tokens: 'fp16',
122
+ vision_encoder: 'q4',
123
+ decoder_model_merged: 'q4f16',
124
+ },
125
+ family: 'mistral',
126
+ toolFormat: 'mistral-toolcalls',
127
+ contextLength: 32768,
128
+ vision: true,
129
+ size: 2_200_000_000,
130
+ label: 'Ministral 3 3B (Vision)',
131
+ },
132
+ };
133
+
134
+ export function getTransformersModel(id: TransformersModelId): TransformersModelEntry {
135
+ return TRANSFORMERS_MODELS[id];
136
+ }
137
+
138
+ export function listTransformersModels(): Array<{ id: TransformersModelId; entry: TransformersModelEntry }> {
139
+ return Object.entries(TRANSFORMERS_MODELS).map(([id, entry]) => ({
140
+ id: id as TransformersModelId,
141
+ entry,
142
+ }));
143
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pure serializer: ChatMessage[] → flat [{role, content}] array suitable for
3
+ * tokenizer.apply_chat_template in the transformers.js worker.
4
+ *
5
+ * Extracted from TransformersProvider so it can be unit-tested without
6
+ * spinning up a Web Worker. Tool calls and tool results are rendered as
7
+ * textual spans using the wire format each model family expects:
8
+ *
9
+ * - Qwen (ChatML): <tool_call>{json}</tool_call>
10
+ * <tool_response>{json}</tool_response>
11
+ * - Mistral: [TOOL_CALLS][{json}]
12
+ * [TOOL_RESULTS] {json} [/TOOL_RESULTS]
13
+ * - Gemma: legacy path (this serializer is not used — see
14
+ * buildGemmaPrompt in prompts/gemma4-prompt-builder.ts).
15
+ *
16
+ * The actual role tags (<|im_start|>user\n…<|im_end|>, [INST]…[/INST]) are
17
+ * added by apply_chat_template from the model's baked-in chat_template.
18
+ */
19
+ import type { ChatMessage, ContentBlock } from '../types.js';
20
+ import { formatToolCall, formatToolResponse } from '../prompts/gemma4-prompt-builder.js';
21
+
22
+ export type PromptKind = 'gemma' | 'qwen' | 'mistral';
23
+
24
+ export function serializeMessagesForTemplate(
25
+ messages: ChatMessage[],
26
+ promptKind: PromptKind,
27
+ ): Array<{ role: string; content: string }> {
28
+ const out: Array<{ role: string; content: string }> = [];
29
+ for (const msg of messages) {
30
+ const role = msg.role; // 'user' | 'assistant' | 'system'
31
+ if (typeof msg.content === 'string') {
32
+ out.push({ role, content: msg.content });
33
+ continue;
34
+ }
35
+ const segments: string[] = [];
36
+ let toolResultBuf: string[] = [];
37
+ const flushToolResults = () => {
38
+ if (toolResultBuf.length === 0) return;
39
+ if (promptKind === 'qwen') {
40
+ for (const tr of toolResultBuf) {
41
+ segments.push(`<tool_response>\n${tr}\n</tool_response>`);
42
+ }
43
+ } else if (promptKind === 'mistral') {
44
+ for (const tr of toolResultBuf) {
45
+ segments.push(`[TOOL_RESULTS] ${tr} [/TOOL_RESULTS]`);
46
+ }
47
+ } else {
48
+ // Gemma path — kept for defensive completeness; main code uses
49
+ // buildGemmaPrompt instead.
50
+ for (const tr of toolResultBuf) segments.push(formatToolResponse(tr));
51
+ }
52
+ toolResultBuf = [];
53
+ };
54
+ for (const block of msg.content as ContentBlock[]) {
55
+ if (block.type === 'text') {
56
+ segments.push(block.text);
57
+ } else if (block.type === 'tool_use') {
58
+ if (promptKind === 'qwen') {
59
+ segments.push(`<tool_call>\n${JSON.stringify({ name: block.name, arguments: block.input })}\n</tool_call>`);
60
+ } else if (promptKind === 'mistral') {
61
+ segments.push(`[TOOL_CALLS][${JSON.stringify({ name: block.name, arguments: block.input })}]`);
62
+ } else {
63
+ segments.push(formatToolCall(block.name, block.input));
64
+ }
65
+ } else if (block.type === 'tool_result') {
66
+ toolResultBuf.push(block.content);
67
+ }
68
+ // 'image' blocks are not reachable here: vision turns go through the
69
+ // legacy `prompt` path in TransformersProvider.chat().
70
+ }
71
+ flushToolResults();
72
+ // Promote pure-tool-result turns: Qwen uses the 'tool' role, Mistral
73
+ // keeps 'user' (the template wraps tool results inside a user turn).
74
+ const onlyToolResult = (msg.content as ContentBlock[]).every(b => b.type === 'tool_result');
75
+ const effectiveRole = onlyToolResult && role === 'user'
76
+ ? (promptKind === 'qwen' ? 'tool' : 'user')
77
+ : role;
78
+ out.push({ role: effectiveRole, content: segments.join('\n') });
79
+ }
80
+ return out;
81
+ }