4 Commits
0.2.3 ... 0.2.7

Author SHA1 Message Date
d5bf1ec47e Pulled chunking out into its own exported function for easy access
All checks were successful
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 7s
2026-01-30 10:38:51 -05:00
cb60a0b0c5 Moved embeddings to worker to prevent blocking
All checks were successful
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 7s
2026-01-28 22:17:39 -05:00
1c59379c7d Set tesseract model
All checks were successful
Publish Library / Build NPM Project (push) Successful in 31s
Publish Library / Tag Version (push) Successful in 5s
2026-01-16 20:33:51 -05:00
6dce0e8954 Fixed tool calls
All checks were successful
Publish Library / Build NPM Project (push) Successful in 39s
Publish Library / Tag Version (push) Successful in 8s
2025-12-27 17:27:53 -05:00
10 changed files with 103 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "0.2.3", "version": "0.2.7",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -1,22 +1,26 @@
import * as os from 'node:os';
import {LLM, LLMOptions} from './llm'; import {LLM, LLMOptions} from './llm';
import { Audio } from './audio.ts'; import { Audio } from './audio.ts';
import {Vision} from './vision.ts'; import {Vision} from './vision.ts';
export type AiOptions = LLMOptions & { export type AiOptions = LLMOptions & {
/** Path to models */
path?: string;
/** Whisper ASR configuration */
whisper?: { whisper?: {
/** Whisper binary location */ /** Whisper binary location */
binary: string; binary: string;
/** Model: `ggml-base.en.bin` */ /** Model: `ggml-base.en.bin` */
model: string; model: string;
} }
/** Path to models */ /** Tesseract OCR configuration */
path: string; tesseract?: {
/** Model: eng, eng_best, eng_fast */
model?: string;
}
} }
export class Ai { export class Ai {
private downloads: {[key: string]: Promise<string>} = {};
private whisperModel!: string;
/** Audio processing AI */ /** Audio processing AI */
audio!: Audio; audio!: Audio;
/** Language processing AI */ /** Language processing AI */
@@ -25,6 +29,7 @@ export class Ai {
vision!: Vision; vision!: Vision;
constructor(public readonly options: AiOptions) { constructor(public readonly options: AiOptions) {
if(!options.path) options.path = os.tmpdir();
process.env.TRANSFORMERS_CACHE = options.path; process.env.TRANSFORMERS_CACHE = options.path;
this.audio = new Audio(this); this.audio = new Audio(this);
this.language = new LLM(this); this.language = new LLM(this);

View File

@@ -54,12 +54,14 @@ export class Anthropic extends LLMProvider {
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
const original = deepCopy(history); const original = deepCopy(history);
if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min, options); if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min, options);
const tools = options.tools || this.ai.options.tools || [];
const requestParams: any = { const requestParams: any = {
model: options.model || this.model, model: options.model || this.model,
max_tokens: options.max_tokens || this.ai.options.max_tokens || 4096, max_tokens: options.max_tokens || this.ai.options.max_tokens || 4096,
system: options.system || this.ai.options.system || '', system: options.system || this.ai.options.system || '',
temperature: options.temperature || this.ai.options.temperature || 0.7, temperature: options.temperature || this.ai.options.temperature || 0.7,
tools: (options.tools || this.ai.options.tools || []).map(t => ({ tools: tools.map(t => ({
name: t.name, name: t.name,
description: t.description, description: t.description,
input_schema: { input_schema: {
@@ -76,7 +78,10 @@ export class Anthropic extends LLMProvider {
let resp: any, isFirstMessage = true; let resp: any, isFirstMessage = true;
const assistantMessages: string[] = []; const assistantMessages: string[] = [];
do { do {
resp = await this.client.messages.create(requestParams); resp = await this.client.messages.create(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err;
});
// Streaming mode // Streaming mode
if(options.stream) { if(options.stream) {
@@ -114,7 +119,7 @@ export class Anthropic extends LLMProvider {
history.push({role: 'assistant', content: resp.content}); history.push({role: 'assistant', content: resp.content});
original.push({role: 'assistant', content: resp.content}); original.push({role: 'assistant', content: resp.content});
const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = options.tools?.find(findByProp('name', toolCall.name)); const tool = tools.find(findByProp('name', toolCall.name));
if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'}; if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'};
try { try {
const result = await tool.fn(toolCall.input, this.ai); const result = await tool.fn(toolCall.input, this.ai);

View File

@@ -48,7 +48,7 @@ export class Audio {
async downloadAsrModel(model: string = this.whisperModel): Promise<string> { async downloadAsrModel(model: string = this.whisperModel): Promise<string> {
if(!this.ai.options.whisper?.binary) throw new Error('Whisper not configured'); if(!this.ai.options.whisper?.binary) throw new Error('Whisper not configured');
if(!model.endsWith('.bin')) model += '.bin'; if(!model.endsWith('.bin')) model += '.bin';
const p = Path.join(this.ai.options.path, model); const p = Path.join(<string>this.ai.options.path, model);
if(await fs.stat(p).then(() => true).catch(() => false)) return p; if(await fs.stat(p).then(() => true).catch(() => false)) return p;
if(!!this.downloads[model]) return this.downloads[model]; if(!!this.downloads[model]) return this.downloads[model];
this.downloads[model] = fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${model}`) this.downloads[model] = fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${model}`)

11
src/embedder.ts Normal file
View File

@@ -0,0 +1,11 @@
import { pipeline } from '@xenova/transformers';
import { parentPort } from 'worker_threads';
let model: any;
parentPort?.on('message', async ({ id, text }) => {
if(!model) model = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
const output = await model(text, { pooling: 'mean', normalize: true });
const embedding = Array.from(output.data);
parentPort?.postMessage({ id, embedding });
});

View File

@@ -1,4 +1,3 @@
import {pipeline} from '@xenova/transformers';
import {JSONAttemptParse} from '@ztimson/utils'; import {JSONAttemptParse} from '@ztimson/utils';
import {Ai} from './ai.ts'; import {Ai} from './ai.ts';
import {Anthropic} from './antrhopic.ts'; import {Anthropic} from './antrhopic.ts';
@@ -6,7 +5,9 @@ import {Ollama} from './ollama.ts';
import {OpenAi} from './open-ai.ts'; import {OpenAi} from './open-ai.ts';
import {AbortablePromise, LLMProvider} from './provider.ts'; import {AbortablePromise, LLMProvider} from './provider.ts';
import {AiTool} from './tools.ts'; import {AiTool} from './tools.ts';
import * as tf from '@tensorflow/tfjs'; import {Worker} from 'worker_threads';
import {fileURLToPath} from 'url';
import {dirname, join} from 'path';
export type LLMMessage = { export type LLMMessage = {
/** Message originator */ /** Message originator */
@@ -83,11 +84,22 @@ export type LLMRequest = {
} }
export class LLM { export class LLM {
private embedModel: any; private embedWorker: Worker | null = null;
private embedQueue = new Map<number, { resolve: (value: number[]) => void; reject: (error: any) => void }>();
private embedId = 0;
private providers: {[key: string]: LLMProvider} = {}; private providers: {[key: string]: LLMProvider} = {};
constructor(public readonly ai: Ai) { constructor(public readonly ai: Ai) {
this.embedModel = pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2'); this.embedWorker = new Worker(join(dirname(fileURLToPath(import.meta.url)), 'embedder.js'));
this.embedWorker.on('message', ({ id, embedding }) => {
const pending = this.embedQueue.get(id);
if (pending) {
pending.resolve(embedding);
this.embedQueue.delete(id);
}
});
if(ai.options.anthropic?.token) this.providers.anthropic = new Anthropic(this.ai, ai.options.anthropic.token, ai.options.anthropic.model); if(ai.options.anthropic?.token) this.providers.anthropic = new Anthropic(this.ai, ai.options.anthropic.token, ai.options.anthropic.model);
if(ai.options.ollama?.host) this.providers.ollama = new Ollama(this.ai, ai.options.ollama.host, ai.options.ollama.model); if(ai.options.ollama?.host) this.providers.ollama = new Ollama(this.ai, ai.options.ollama.host, ai.options.ollama.model);
if(ai.options.openAi?.token) this.providers.openAi = new OpenAi(this.ai, ai.options.openAi.token, ai.options.openAi.model); if(ai.options.openAi?.token) this.providers.openAi = new OpenAi(this.ai, ai.options.openAi.token, ai.options.openAi.model);
@@ -148,49 +160,44 @@ export class LLM {
return denominator === 0 ? 0 : dotProduct / denominator; return denominator === 0 ? 0 : dotProduct / denominator;
} }
embedding(target: object | string, maxTokens = 500, overlapTokens = 50) { chunk(target: object | string, maxTokens = 500, overlapTokens = 50): string[] {
const objString = (obj: any, path = ''): string[] => { const objString = (obj: any, path = ''): string[] => {
if(obj === null || obj === undefined) return []; if(!obj) return [];
return Object.entries(obj).flatMap(([key, value]) => { return Object.entries(obj).flatMap(([key, value]) => {
const p = path ? `${path}${isNaN(+key) ? `.${key}` : `[${key}]`}` : key; const p = path ? `${path}${isNaN(+key) ? `.${key}` : `[${key}]`}` : key;
if(typeof value === 'object' && value !== null && !Array.isArray(value)) return objString(value, p); if(typeof value === 'object' && !Array.isArray(value)) return objString(value, p);
const valueStr = Array.isArray(value) ? value.join(', ') : String(value); return `${p}: ${Array.isArray(value) ? value.join(', ') : value}`;
return `${p}: ${valueStr}`;
}); });
}; };
const embed = async (text: string): Promise<number[]> => { const lines = typeof target === 'object' ? objString(target) : target.split('\n');
const model = await this.embedModel; const tokens = lines.flatMap(l => [...l.split(/\s+/).filter(Boolean), '\n']);
const output = await model(text, {pooling: 'mean', normalize: true}); const chunks: string[] = [];
return Array.from(output.data); for(let i = 0; i < tokens.length;) {
let text = '', j = i;
while(j < tokens.length) {
const next = text + (text ? ' ' : '') + tokens[j];
if(this.estimateTokens(next.replace(/\s*\n\s*/g, '\n')) > maxTokens && text) break;
text = next;
j++;
}
const clean = text.replace(/\s*\n\s*/g, '\n').trim();
if(clean) chunks.push(clean);
i = Math.max(j - overlapTokens, j === i ? i + 1 : j);
}
return chunks;
}
embedding(target: object | string, maxTokens = 500, overlapTokens = 50) {
const embed = (text: string): Promise<number[]> => {
return new Promise((resolve, reject) => {
const id = this.embedId++;
this.embedQueue.set(id, { resolve, reject });
this.embedWorker?.postMessage({ id, text });
});
}; };
// Tokenize const chunks = this.chunk(target, maxTokens, overlapTokens);
const lines = typeof target === 'object' ? objString(target) : target.split('\n');
const tokens = lines.flatMap(line => [...line.split(/\s+/).filter(w => w.trim()), '\n']);
// Chunk
const chunks: string[] = [];
let start = 0;
while (start < tokens.length) {
let end = start;
let text = '';
// Build chunk
while (end < tokens.length) {
const nextToken = tokens[end];
const testText = text + (text ? ' ' : '') + nextToken;
const testTokens = this.estimateTokens(testText.replace(/\s*\n\s*/g, '\n'));
if (testTokens > maxTokens && text) break;
text = testText;
end++;
}
// Save chunk
const cleanText = text.replace(/\s*\n\s*/g, '\n').trim();
if(cleanText) chunks.push(cleanText);
start = end - overlapTokens;
if (start <= end - tokens.length + end) start = end; // Safety: prevent infinite loop
}
return Promise.all(chunks.map(async (text, index) => ({ return Promise.all(chunks.map(async (text, index) => ({
index, index,
embedding: await embed(text), embedding: await embed(text),

View File

@@ -49,6 +49,7 @@ export class Ollama extends LLMProvider {
if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min); if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min);
if(options.system) history.unshift({role: 'system', content: system}) if(options.system) history.unshift({role: 'system', content: system})
const tools = options.tools || this.ai.options.tools || [];
const requestParams: any = { const requestParams: any = {
model: options.model || this.model, model: options.model || this.model,
messages: history, messages: history,
@@ -58,7 +59,7 @@ export class Ollama extends LLMProvider {
temperature: options.temperature || this.ai.options.temperature || 0.7, temperature: options.temperature || this.ai.options.temperature || 0.7,
num_predict: options.max_tokens || this.ai.options.max_tokens || 4096, num_predict: options.max_tokens || this.ai.options.max_tokens || 4096,
}, },
tools: (options.tools || this.ai.options.tools || []).map(t => ({ tools: tools.map(t => ({
type: 'function', type: 'function',
function: { function: {
name: t.name, name: t.name,
@@ -74,7 +75,11 @@ export class Ollama extends LLMProvider {
let resp: any, isFirstMessage = true; let resp: any, isFirstMessage = true;
do { do {
resp = await this.client.chat(requestParams); resp = await this.client.chat(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err;
});
if(options.stream) { if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'}); if(!isFirstMessage) options.stream({text: '\n\n'});
else isFirstMessage = false; else isFirstMessage = false;
@@ -93,7 +98,7 @@ export class Ollama extends LLMProvider {
if(resp.message?.tool_calls?.length && !controller.signal.aborted) { if(resp.message?.tool_calls?.length && !controller.signal.aborted) {
history.push(resp.message); history.push(resp.message);
const results = await Promise.all(resp.message.tool_calls.map(async (toolCall: any) => { const results = await Promise.all(resp.message.tool_calls.map(async (toolCall: any) => {
const tool = (options.tools || this.ai.options.tools)?.find(findByProp('name', toolCall.function.name)); const tool = tools.find(findByProp('name', toolCall.function.name));
if(!tool) return {role: 'tool', tool_name: toolCall.function.name, content: '{"error": "Tool not found"}'}; if(!tool) return {role: 'tool', tool_name: toolCall.function.name, content: '{"error": "Tool not found"}'};
const args = typeof toolCall.function.arguments === 'string' ? JSONAttemptParse(toolCall.function.arguments, {}) : toolCall.function.arguments; const args = typeof toolCall.function.arguments === 'string' ? JSONAttemptParse(toolCall.function.arguments, {}) : toolCall.function.arguments;
try { try {

View File

@@ -67,13 +67,14 @@ export class OpenAi extends LLMProvider {
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min, options); if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min, options);
const tools = options.tools || this.ai.options.tools || [];
const requestParams: any = { const requestParams: any = {
model: options.model || this.model, model: options.model || this.model,
messages: history, messages: history,
stream: !!options.stream, stream: !!options.stream,
max_tokens: options.max_tokens || this.ai.options.max_tokens || 4096, max_tokens: options.max_tokens || this.ai.options.max_tokens || 4096,
temperature: options.temperature || this.ai.options.temperature || 0.7, temperature: options.temperature || this.ai.options.temperature || 0.7,
tools: (options.tools || this.ai.options.tools || []).map(t => ({ tools: tools.map(t => ({
type: 'function', type: 'function',
function: { function: {
name: t.name, name: t.name,
@@ -89,7 +90,11 @@ export class OpenAi extends LLMProvider {
let resp: any, isFirstMessage = true; let resp: any, isFirstMessage = true;
do { do {
resp = await this.client.chat.completions.create(requestParams); resp = await this.client.chat.completions.create(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err;
});
if(options.stream) { if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'}); if(!isFirstMessage) options.stream({text: '\n\n'});
else isFirstMessage = false; else isFirstMessage = false;
@@ -110,7 +115,7 @@ export class OpenAi extends LLMProvider {
if(toolCalls.length && !controller.signal.aborted) { if(toolCalls.length && !controller.signal.aborted) {
history.push(resp.choices[0].message); history.push(resp.choices[0].message);
const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = options.tools?.find(findByProp('name', toolCall.function.name)); const tool = tools?.find(findByProp('name', toolCall.function.name));
if(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}'}; if(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}'};
try { try {
const args = JSONAttemptParse(toolCall.function.arguments, {}); const args = JSONAttemptParse(toolCall.function.arguments, {});

View File

@@ -15,7 +15,7 @@ export class Vision {
return { return {
abort: () => { worker?.terminate(); }, abort: () => { worker?.terminate(); },
response: new Promise(async res => { response: new Promise(async res => {
worker = await createWorker('eng', 1, {cachePath: this.ai.options.path}); worker = await createWorker(this.ai.options.tesseract?.model || 'eng', 2, {cachePath: this.ai.options.path});
const {data} = await worker.recognize(path); const {data} = await worker.recognize(path);
await worker.terminate(); await worker.terminate();
res(data.text.trim() || null); res(data.text.trim() || null);

View File

@@ -1,12 +1,19 @@
import {defineConfig} from 'vite'; import {defineConfig} from 'vite';
import dts from 'vite-plugin-dts'; import dts from 'vite-plugin-dts';
import {resolve} from 'path';
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { lib: {
entry: './src/index.ts', entry: {
index: './src/index.ts',
embedder: './src/embedder.ts',
},
name: 'utils', name: 'utils',
fileName: (format) => (format === 'es' ? 'index.mjs' : 'index.js'), fileName: (format, entryName) => {
if (entryName === 'embedder') return 'embedder.js';
return format === 'es' ? 'index.mjs' : 'index.js';
},
}, },
ssr: true, ssr: true,
emptyOutDir: true, emptyOutDir: true,