@tanstack-ai-browser/wllama 0.0.0-stage → 0.2.1

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/dist/adapter.d.ts +176 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +195 -0
  6. package/dist/audio.d.ts +16 -0
  7. package/dist/audio.d.ts.map +1 -0
  8. package/dist/audio.js +80 -0
  9. package/dist/catalog.d.ts +104 -0
  10. package/dist/catalog.d.ts.map +1 -0
  11. package/dist/catalog.js +307 -0
  12. package/dist/client.d.ts +64 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +148 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +7 -0
  18. package/dist/model-memory.d.ts +46 -0
  19. package/dist/model-memory.d.ts.map +1 -0
  20. package/dist/model-memory.js +79 -0
  21. package/dist/model-meta.d.ts +7 -0
  22. package/dist/model-meta.d.ts.map +1 -0
  23. package/dist/model-meta.js +5 -0
  24. package/dist/shared-load.d.ts +48 -0
  25. package/dist/shared-load.d.ts.map +1 -0
  26. package/dist/shared-load.js +103 -0
  27. package/dist/store.d.ts +8 -0
  28. package/dist/store.d.ts.map +1 -0
  29. package/dist/store.js +17 -0
  30. package/dist/template.d.ts +26 -0
  31. package/dist/template.d.ts.map +1 -0
  32. package/dist/template.js +25 -0
  33. package/dist/thinking.d.ts +39 -0
  34. package/dist/thinking.d.ts.map +1 -0
  35. package/dist/thinking.js +140 -0
  36. package/dist/tool-calls.d.ts +33 -0
  37. package/dist/tool-calls.d.ts.map +1 -0
  38. package/dist/tool-calls.js +203 -0
  39. package/package.json +63 -4
  40. package/src/adapter.ts +345 -0
  41. package/src/audio.test.ts +83 -0
  42. package/src/audio.ts +79 -0
  43. package/src/catalog.ts +399 -0
  44. package/src/client.ts +195 -0
  45. package/src/index.ts +11 -0
  46. package/src/runtime.test.ts +220 -0
  47. package/src/wllama-version.test.ts +19 -0
  48. package/src/wrapper.test.ts +186 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Splitting a reply into thinking and answer. Copied from
3
+ * @tanstack-ai-browser/transformers-js (thinking.ts), so this package
4
+ * doesn't depend on that one and its worker.
5
+ */
6
+ /** `<think>…</think>`, as LFM models (and most others) write it. */
7
+ export const THINK_TAGS = {
8
+ open: "<think>",
9
+ close: "</think>"
10
+ };
11
+ /**
12
+ * Splits a model's reply into its thinking and its answer. Thinking is any
13
+ * `<think>…</think>` block (or the format's own markers), or everything
14
+ * before a lone close (models prompted with an empty `<think></think>`
15
+ * sometimes still think a little and close it). An open with no close yet
16
+ * means it's still thinking.
17
+ */
18
+ export function splitThinking(text, { open: OPEN, close: CLOSE } = THINK_TAGS) {
19
+ const thoughts = [];
20
+ let rest = text;
21
+ let isThinking = false;
22
+ for (;;) {
23
+ const open = rest.indexOf(OPEN);
24
+ const close = rest.indexOf(CLOSE);
25
+ if (close !== -1 && (open === -1 || close < open)) {
26
+ // A close with no open: everything before it was thinking.
27
+ thoughts.push(rest.slice(0, close));
28
+ rest = rest.slice(close + CLOSE.length);
29
+ continue;
30
+ }
31
+ if (open === -1)
32
+ break;
33
+ const before = rest.slice(0, open);
34
+ const after = rest.slice(open + OPEN.length);
35
+ const end = after.indexOf(CLOSE);
36
+ if (end === -1) {
37
+ thoughts.push(after);
38
+ rest = before;
39
+ isThinking = true;
40
+ break;
41
+ }
42
+ thoughts.push(after.slice(0, end));
43
+ rest = before + after.slice(end + CLOSE.length);
44
+ }
45
+ return {
46
+ thinking: thoughts
47
+ .map((t) => t.trim())
48
+ .filter(Boolean)
49
+ .join("\n\n"),
50
+ answer: rest.trim(),
51
+ isThinking
52
+ };
53
+ }
54
+ /** Length of the longest end of `text` that could be the start of `tag`. */
55
+ function partialTag(text, tag) {
56
+ for (let n = Math.min(tag.length - 1, text.length); n > 0; n--) {
57
+ if (tag.startsWith(text.slice(-n)))
58
+ return n;
59
+ }
60
+ return 0;
61
+ }
62
+ /**
63
+ * Splits a reply into thinking and answer while it streams. A reply that
64
+ * starts with the open marker (`<think>`) is thinking until the close
65
+ * (`</think>`); the rest is the answer. Markers split across tokens are held
66
+ * back until they are complete.
67
+ */
68
+ export function createThinkingSplitter({ open: OPEN, close: CLOSE } = THINK_TAGS) {
69
+ let state = "start";
70
+ let buffer = "";
71
+ /** Whether the thinking has had any text yet (to drop a leading newline). */
72
+ let thought = false;
73
+ /** Whether the answer has had any text yet (to drop leading blank lines). */
74
+ let answered = false;
75
+ const answer = (text) => {
76
+ const out = answered ? text : text.trimStart();
77
+ if (!out)
78
+ return [];
79
+ answered = true;
80
+ return [{ kind: "answer", text: out }];
81
+ };
82
+ function push(text) {
83
+ buffer += text;
84
+ const pieces = [];
85
+ for (;;) {
86
+ if (state === "start") {
87
+ const lead = buffer.trimStart();
88
+ if (lead.startsWith(OPEN)) {
89
+ buffer = lead.slice(OPEN.length);
90
+ state = "thinking";
91
+ }
92
+ else if (OPEN.startsWith(lead)) {
93
+ return pieces;
94
+ }
95
+ else {
96
+ state = "answer";
97
+ }
98
+ }
99
+ else if (state === "thinking") {
100
+ const end = buffer.indexOf(CLOSE);
101
+ if (end === -1) {
102
+ const keep = partialTag(buffer, CLOSE);
103
+ const done = buffer.slice(0, buffer.length - keep);
104
+ buffer = buffer.slice(done.length);
105
+ const text = thought ? done : done.trimStart();
106
+ if (text) {
107
+ thought = true;
108
+ pieces.push({ kind: "thinking", text });
109
+ }
110
+ return pieces;
111
+ }
112
+ const text = thought
113
+ ? buffer.slice(0, end)
114
+ : buffer.slice(0, end).trimStart();
115
+ if (text) {
116
+ thought = true;
117
+ pieces.push({ kind: "thinking", text });
118
+ }
119
+ buffer = buffer.slice(end + CLOSE.length);
120
+ state = "answer";
121
+ }
122
+ else {
123
+ pieces.push(...answer(buffer));
124
+ buffer = "";
125
+ return pieces;
126
+ }
127
+ }
128
+ }
129
+ /** Whatever is still held back, once the reply has ended. */
130
+ function flush() {
131
+ const rest = buffer;
132
+ buffer = "";
133
+ if (!rest)
134
+ return [];
135
+ return state === "thinking"
136
+ ? [{ kind: "thinking", text: rest }]
137
+ : answer(rest);
138
+ }
139
+ return { push, flush };
140
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reading and writing tool calls the way LFM2 and LFM2.5 write them:
3
+ * Pythonic calls between markers,
4
+ * `<|tool_call_start|>[get_weather(city="Tokyo")]<|tool_call_end|>`.
5
+ *
6
+ * Copied from @tanstack-ai-browser/transformers-js (tool-calls.ts, its LFM2
7
+ * parts), so this package doesn't depend on that one and its worker.
8
+ */
9
+ export type ParsedToolCall = {
10
+ name: string;
11
+ args: Record<string, unknown>;
12
+ };
13
+ /** How a model writes tool calls. */
14
+ export type ToolFormat = "lfm2";
15
+ export declare const TOOL_CALL_START = "<|tool_call_start|>";
16
+ export declare const TOOL_CALL_END = "<|tool_call_end|>";
17
+ /** Every tool call in `text`. */
18
+ export declare function parseToolCalls(text: string): Array<ParsedToolCall>;
19
+ /**
20
+ * Splits a streaming answer into the text before the first tool call (passed
21
+ * through as it arrives) and the tool-call markup after it (held back and
22
+ * parsed once the reply ends).
23
+ */
24
+ export declare function createToolCallSplitter(markers?: Array<string>): {
25
+ push: (text: string) => string;
26
+ flush: () => {
27
+ text: string;
28
+ markup: string | null;
29
+ };
30
+ };
31
+ /** A past tool call in LFM2.5's own format, for the assistant turn's text. */
32
+ export declare function formatLfm2ToolCalls(calls: Array<ParsedToolCall>): string;
33
+ //# sourceMappingURL=tool-calls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-calls.d.ts","sourceRoot":"","sources":["../src/tool-calls.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,cAAc,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAA;AAE5E,qCAAqC;AACrC,MAAM,MAAM,UAAU,GAAG,MAAM,CAAA;AAE/B,eAAO,MAAM,eAAe,wBAAwB,CAAA;AACpD,eAAO,MAAM,aAAa,sBAAsB,CAAA;AA+GhD,iCAAiC;AACjC,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC,cAAc,CAAC,CAQlE;AAWD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,GAAE,KAAK,CAAC,MAAM,CAAqB;iBAMtB,MAAM;;;;;EA6B3B;AAUD,8EAA8E;AAC9E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,KAAK,CAAC,cAAc,CAAC,UAU/D"}
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Reading and writing tool calls the way LFM2 and LFM2.5 write them:
3
+ * Pythonic calls between markers,
4
+ * `<|tool_call_start|>[get_weather(city="Tokyo")]<|tool_call_end|>`.
5
+ *
6
+ * Copied from @tanstack-ai-browser/transformers-js (tool-calls.ts, its LFM2
7
+ * parts), so this package doesn't depend on that one and its worker.
8
+ */
9
+ export const TOOL_CALL_START = "<|tool_call_start|>";
10
+ export const TOOL_CALL_END = "<|tool_call_end|>";
11
+ /** A Python-style or JSON value: "x", 'x', 1.5, True, None, [1, 2]… */
12
+ function parseValue(raw) {
13
+ const s = raw.trim();
14
+ if (/^(["']).*\1$/s.test(s)) {
15
+ try {
16
+ return JSON.parse(s[0] === "'" ? `"${s.slice(1, -1)}"` : s);
17
+ }
18
+ catch {
19
+ return s.slice(1, -1);
20
+ }
21
+ }
22
+ if (s === "True" || s === "true")
23
+ return true;
24
+ if (s === "False" || s === "false")
25
+ return false;
26
+ if (s === "None" || s === "null")
27
+ return null;
28
+ if (s !== "" && !Number.isNaN(Number(s)))
29
+ return Number(s);
30
+ try {
31
+ return JSON.parse(s);
32
+ }
33
+ catch {
34
+ return s;
35
+ }
36
+ }
37
+ /** After a value: the next argument (`, name=`), or the end of the call. */
38
+ const VALUE_END = /^\s*(?:,\s*[A-Za-z_]\w*\s*=|,?\s*\)|$)/;
39
+ /**
40
+ * Where the string starting at `body[start]` ends (the index of its closing
41
+ * quote). The model doesn't escape quotes inside its strings ('JWST's
42
+ * findings'), so a quote only closes the string when the argument ends
43
+ * after it; otherwise it's part of the text.
44
+ */
45
+ function stringEnd(body, start) {
46
+ const quote = body[start];
47
+ let last = -1;
48
+ for (let i = start + 1; i < body.length; i++) {
49
+ if (body[i] === "\\")
50
+ i++;
51
+ else if (body[i] === quote) {
52
+ if (VALUE_END.test(body.slice(i + 1)))
53
+ return i;
54
+ last = i;
55
+ }
56
+ }
57
+ return last;
58
+ }
59
+ /** A Python string literal's text, `\'` and `\"` included. */
60
+ function stringText(raw) {
61
+ const inner = raw.slice(1, -1);
62
+ try {
63
+ const escaped = inner.replace(/\\'/g, "'").replace(/(^|[^\\])"/g, '$1\\"');
64
+ return JSON.parse(`"${escaped}"`);
65
+ }
66
+ catch {
67
+ return inner;
68
+ }
69
+ }
70
+ /** Where a `[…]` or `{…}` starting at `body[start]` ends, strings skipped. */
71
+ function bracketEnd(body, start) {
72
+ let depth = 0;
73
+ for (let i = start; i < body.length; i++) {
74
+ const c = body[i];
75
+ if (c === '"' || c === "'") {
76
+ const end = stringEnd(body, i);
77
+ if (end < 0)
78
+ return -1;
79
+ i = end;
80
+ }
81
+ else if (c === "[" || c === "{")
82
+ depth++;
83
+ else if ((c === "]" || c === "}") && --depth === 0)
84
+ return i;
85
+ }
86
+ return -1;
87
+ }
88
+ /** `[name(a="x", b=2), other()]` → calls. */
89
+ function parsePythonic(body) {
90
+ const calls = [];
91
+ const callStart = /([A-Za-z_][\w.-]*)\(/g;
92
+ let m;
93
+ while ((m = callStart.exec(body))) {
94
+ const args = {};
95
+ let i = m.index + m[0].length;
96
+ for (;;) {
97
+ const key = /^\s*([A-Za-z_]\w*)\s*=\s*/.exec(body.slice(i));
98
+ if (!key)
99
+ break;
100
+ i += key[0].length;
101
+ const first = body[i];
102
+ let end;
103
+ if (first === '"' || first === "'") {
104
+ end = stringEnd(body, i);
105
+ if (end < 0)
106
+ break;
107
+ args[key[1]] = stringText(body.slice(i, end + 1));
108
+ }
109
+ else if (first === "[" || first === "{") {
110
+ end = bracketEnd(body, i);
111
+ if (end < 0)
112
+ break;
113
+ args[key[1]] = parseValue(body.slice(i, end + 1));
114
+ }
115
+ else {
116
+ const rest = /^[^,)]*/.exec(body.slice(i))?.[0] ?? "";
117
+ end = i + rest.length - 1;
118
+ args[key[1]] = parseValue(rest);
119
+ }
120
+ i = end + 1;
121
+ const comma = /^\s*,/.exec(body.slice(i));
122
+ if (!comma)
123
+ break;
124
+ i += comma[0].length;
125
+ }
126
+ calls.push({ name: m[1], args });
127
+ // The next call starts after this one's closing parenthesis.
128
+ const close = body.indexOf(")", i);
129
+ callStart.lastIndex = close < 0 ? body.length : close + 1;
130
+ }
131
+ return calls;
132
+ }
133
+ /** Every tool call in `text`. */
134
+ export function parseToolCalls(text) {
135
+ const calls = [];
136
+ for (const m of text.matchAll(/<\|tool_call_start\|>([\s\S]*?)(?:<\|tool_call_end\|>|$)/g)) {
137
+ calls.push(...parsePythonic(m[1]));
138
+ }
139
+ return calls;
140
+ }
141
+ /** Length of the longest end of `text` that could start one of `markers`. */
142
+ function partialMarker(text, markers) {
143
+ for (let n = Math.min(text.length, 18); n > 0; n--) {
144
+ const end = text.slice(-n);
145
+ if (markers.some((m) => m.startsWith(end)))
146
+ return n;
147
+ }
148
+ return 0;
149
+ }
150
+ /**
151
+ * Splits a streaming answer into the text before the first tool call (passed
152
+ * through as it arrives) and the tool-call markup after it (held back and
153
+ * parsed once the reply ends).
154
+ */
155
+ export function createToolCallSplitter(markers = [TOOL_CALL_START]) {
156
+ let held = "";
157
+ let markup = null;
158
+ /** Text that can be shown now. */
159
+ function push(text) {
160
+ if (markup !== null) {
161
+ markup += text;
162
+ return "";
163
+ }
164
+ held += text;
165
+ const at = Math.min(...markers.map((m) => held.indexOf(m)).filter((i) => i >= 0));
166
+ if (Number.isFinite(at)) {
167
+ const before = held.slice(0, at);
168
+ markup = held.slice(at);
169
+ held = "";
170
+ return before;
171
+ }
172
+ const keep = partialMarker(held, markers);
173
+ const out = held.slice(0, held.length - keep);
174
+ held = held.slice(out.length);
175
+ return out;
176
+ }
177
+ /** The rest of the text, and the tool-call markup if any. */
178
+ function flush() {
179
+ const rest = held;
180
+ held = "";
181
+ return { text: rest, markup };
182
+ }
183
+ return { push, flush };
184
+ }
185
+ /** A value written the way Python (and LFM2.5) writes it. */
186
+ function pythonValue(value) {
187
+ if (value === true)
188
+ return "True";
189
+ if (value === false)
190
+ return "False";
191
+ if (value === null || value === undefined)
192
+ return "None";
193
+ return JSON.stringify(value);
194
+ }
195
+ /** A past tool call in LFM2.5's own format, for the assistant turn's text. */
196
+ export function formatLfm2ToolCalls(calls) {
197
+ const list = calls
198
+ .map((c) => `${c.name}(${Object.entries(c.args)
199
+ .map(([k, v]) => `${k}=${pythonValue(v)}`)
200
+ .join(", ")})`)
201
+ .join(", ");
202
+ return `${TOOL_CALL_START}[${list}]${TOOL_CALL_END}`;
203
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@tanstack-ai-browser/wllama",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "TanStack AI adapter for GGUF models running in the browser with wllama (llama.cpp in WebAssembly)",
5
+ "license": "MIT",
6
+ "author": "daveycodez",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/daveycodez/tanstack-ai-browser.git",
10
+ "directory": "packages/wllama"
11
+ },
12
+ "homepage": "https://github.com/daveycodez/tanstack-ai-browser/tree/main/packages/wllama#readme",
13
+ "bugs": "https://github.com/daveycodez/tanstack-ai-browser/issues",
14
+ "keywords": [
15
+ "tanstack-ai",
16
+ "tanstack",
17
+ "ai",
18
+ "llm",
19
+ "wllama",
20
+ "llama.cpp",
21
+ "gguf",
22
+ "webassembly",
23
+ "browser",
24
+ "local-ai"
25
+ ],
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "main": "./dist/index.js",
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ "./package.json": "./package.json",
33
+ ".": {
34
+ "@tanstack-ai-browser/source": "./src/index.ts",
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "default": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "src",
42
+ "dist",
43
+ "!**/*.tsbuildinfo"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "nx": {
49
+ "name": "wllama"
50
+ },
51
+ "peerDependencies": {
52
+ "@tanstack/ai": "^1.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@tanstack/ai": "1.4.0",
56
+ "esbuild": "0.28.2"
57
+ },
58
+ "scripts": {
59
+ "test": "node --import @swc-node/register/esm-register --test \"src/**/*.test.ts\""
60
+ },
61
+ "dependencies": {
62
+ "@tanstack/openai-base": "^1.0.4",
63
+ "@wllama/wllama": "3.13.0"
64
+ }
65
+ }