feat: implemented the v1 browser embedded LLM support (Gemma 4 E4B based)

This commit is contained in:
Xiaohan-Tian
2026-05-14 18:55:23 -07:00
parent 0e6e2736d0
commit 98c2b97413
30 changed files with 19462 additions and 153 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
import { LLMProvider } from '../llm/LLMProvider';
import type { LLMProvider } from '../llm/LLMProvider';
import { AgentState } from './AgentState';
import { SystemPrompts } from './SystemPrompts';
import { AVAILABLE_TOOLS } from '../tools';
@@ -92,7 +92,9 @@ export class AgentCore {
// Add user message to state
this.currentUserMessageId = this.agentState.addMessage('user', userInput);
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
const systemPrompt = await SystemPrompts.getSystemPromptWithContext(
this.llmProvider.getPreferredSystemPromptPath?.(),
);
const tools = this.getToolDefinitions();
try {
+11 -10
View File
@@ -22,25 +22,26 @@ interface SystemPromptContext {
* System prompts for the AI agent with dynamic context loading
*/
export class SystemPrompts {
private static cachedTemplate: string | null = null;
private static cachedTemplates: Map<string, string> = new Map();
private static readonly FALLBACK_PROMPT = `You are K.G.Studio Musician Assistant Agent, a highly skilled music musician with extensive knowledge in music theory, composition, and production.`;
/**
* Load the system prompt template from the public folder
*/
private static async loadTemplate(): Promise<string> {
if (this.cachedTemplate) {
return this.cachedTemplate;
private static async loadTemplate(templatePath: string = 'prompts/system.md'): Promise<string> {
if (this.cachedTemplates.has(templatePath)) {
return this.cachedTemplates.get(templatePath)!;
}
try {
const response = await fetch(`${import.meta.env.BASE_URL}prompts/system.md`);
const response = await fetch(`${import.meta.env.BASE_URL}${templatePath}`);
if (!response.ok) {
throw new Error(`Failed to load system prompt: ${response.status}`);
}
this.cachedTemplate = await response.text();
return this.cachedTemplate;
const template = await response.text();
this.cachedTemplates.set(templatePath, template);
return template;
} catch (error) {
console.error('Failed to load system prompt template:', error);
return this.FALLBACK_PROMPT;
@@ -231,9 +232,9 @@ export class SystemPrompts {
/**
* Get the system prompt with current context applied (backward compatible)
*/
static async getSystemPromptWithContext(): Promise<string> {
static async getSystemPromptWithContext(templatePath?: string): Promise<string> {
try {
const template = await this.loadTemplate();
const template = await this.loadTemplate(templatePath);
let promptWithContext = await this.getPromptWithContext(template);
// Append custom instructions from config if provided
@@ -263,4 +264,4 @@ export class SystemPrompts {
static clearCache(): void {
this.cachedTemplate = null;
}
}
}
+13 -22
View File
@@ -3,16 +3,24 @@ import type { StreamChunk } from './StreamingTypes';
import type { Message, ToolCall } from '../core/AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
export interface LLMProvider {
getPreferredSystemPromptPath?(): string | undefined;
generateStream(
messages: Message[],
systemPrompt?: string,
tools?: OpenAIToolDefinition[],
): AsyncIterableIterator<StreamChunk>;
}
/**
* LLM provider using the OpenAI SDK.
* Works with any OpenAI-compatible API (OpenAI, OpenRouter, Ollama, vLLM, etc.)
* OpenAI-compatible provider implementation.
* Works with OpenAI and OpenAI-compatible APIs (OpenRouter, Ollama, vLLM, etc.)
*/
export class LLMProvider {
export class OpenAICompatibleLLMProvider implements LLMProvider {
private client: OpenAI;
private model: string;
constructor(apiKey: string, model: string, baseURL?: string) {
// The OpenAI SDK appends /chat/completions itself, so strip it if the user included it
const normalizedBaseURL = baseURL?.replace(/\/chat\/completions\/?$/, '') || undefined;
this.client = new OpenAI({
@@ -23,12 +31,9 @@ export class LLMProvider {
this.model = model;
}
/**
* Convert internal Message[] to OpenAI ChatCompletionMessageParam[]
*/
private convertMessages(
messages: Message[],
systemPrompt?: string
systemPrompt?: string,
): OpenAI.ChatCompletionMessageParam[] {
const result: OpenAI.ChatCompletionMessageParam[] = [];
@@ -64,10 +69,6 @@ export class LLMProvider {
return result;
}
/**
* Generate a streaming response from the LLM.
* Yields StreamChunks for text content and tool calls.
*/
async *generateStream(
messages: Message[],
systemPrompt?: string,
@@ -87,8 +88,6 @@ export class LLMProvider {
}
const stream = this.client.chat.completions.stream(requestParams);
// Accumulate tool calls across chunks (they arrive incrementally)
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>();
for await (const chunk of stream) {
@@ -97,23 +96,18 @@ export class LLMProvider {
if (!choice) continue;
const delta = choice.delta;
// Yield text content
if (delta.content) {
yield { type: 'text', content: delta.content };
}
// Accumulate tool calls from deltas
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = toolCallAccumulator.get(tc.index);
if (existing) {
// Append to existing tool call
if (tc.function?.arguments) {
existing.arguments += tc.function.arguments;
}
} else {
// New tool call
toolCallAccumulator.set(tc.index, {
id: tc.id ?? '',
name: tc.function?.name ?? '',
@@ -124,11 +118,9 @@ export class LLMProvider {
}
}
// After stream ends, get the final completion for finish_reason
const finalCompletion = await stream.finalChatCompletion();
const finishReason = finalCompletion.choices[0]?.finish_reason ?? 'stop';
// Emit accumulated tool calls
if (toolCallAccumulator.size > 0) {
for (const [, tc] of toolCallAccumulator) {
const toolCall: ToolCall = {
@@ -140,7 +132,6 @@ export class LLMProvider {
}
}
// Signal completion
yield { type: 'done', content: '', finishReason };
}
}
+301
View File
@@ -0,0 +1,301 @@
import type { StreamChunk } from './StreamingTypes';
import type { Message, ToolCall } from '../core/AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
import { LocalLLMModelManager } from '../../util/localLLMModelManager';
import {
formatToolCall,
formatToolDeclaration,
formatToolResponse,
parseToolCalls,
stripToolProtocol,
} from './gemmaToolProtocol';
import { LOCAL_LLM_MODEL_FILENAME, LOCAL_LLM_MODEL_URL } from '../../util/localLLMConfig';
import { LocalLLMModelCache } from '../../util/localLLMModelCache';
import type { LLMProvider } from './LLMProvider';
type MediaPipeGenAI = {
FilesetResolver: {
forGenAiTasks(basePath: string): Promise<unknown>;
};
LlmInference: {
createFromOptions(fileset: unknown, options: Record<string, unknown>): Promise<GemmaInference>;
};
};
type GemmaInference = {
generateResponse(
prompt: string,
callback: (partial: string, done: boolean) => void,
): Promise<void> | void;
sizeInTokens(text: string): number;
close?: () => void;
};
interface PromptTemplatePart {
pre: string;
post: string;
}
const PROMPT_TEMPLATE: Record<'user' | 'model' | 'system', PromptTemplatePart> = {
user: { pre: '<|turn>user\n', post: '<turn|>\n' },
model: { pre: '<|turn>model\n', post: '<turn|>\n' },
system: { pre: '<|turn>system\n', post: '<turn|>\n' },
};
async function importMediaPipe(): Promise<MediaPipeGenAI> {
const bundleUrl = new URL(`${import.meta.env.BASE_URL}mediapipe/genai_bundle.mjs`, window.location.origin).href;
return import(/* @vite-ignore */ bundleUrl) as Promise<MediaPipeGenAI>;
}
export class LocalBrowserLLMProvider implements LLMProvider {
private inference: GemmaInference | null = null;
getPreferredSystemPromptPath(): string | undefined {
return 'prompts/system_compact.md';
}
private async ensureInference(): Promise<GemmaInference> {
await LocalLLMModelManager.ensureRuntimeSupported();
if (this.inference) {
return this.inference;
}
const [{ FilesetResolver, LlmInference }, modelLoad] = await Promise.all([
this.getMediaPipeModule(),
LocalLLMModelCache.loadModelReaderWithCache(
LOCAL_LLM_MODEL_URL,
LOCAL_LLM_MODEL_FILENAME,
progress => {
if (progress.fromCache) {
LocalLLMModelManager.notifyLoadStart(true);
} else {
LocalLLMModelManager.notifyLoadProgress(progress.receivedBytes, progress.totalBytes, false);
}
},
),
]);
LocalLLMModelManager.notifyLoadStart(modelLoad.fromCache);
console.log('[localLLM] Model stream prepared for MediaPipe.', {
filename: LOCAL_LLM_MODEL_FILENAME,
totalBytes: modelLoad.totalBytes,
fromCache: modelLoad.fromCache,
});
const fileset = await FilesetResolver.forGenAiTasks(`${import.meta.env.BASE_URL}mediapipe/wasm`);
console.log('[localLLM] MediaPipe fileset resolved. Creating inference engine...');
try {
this.inference = await LlmInference.createFromOptions(fileset, {
baseOptions: {
modelAssetBuffer: modelLoad.reader,
},
numResponses: 1,
maxTokens: 32768,
topK: 64,
temperature: 1.0,
});
console.log('[localLLM] MediaPipe inference engine created successfully.');
if (modelLoad.cacheWritePromise) {
void modelLoad.cacheWritePromise.then(() => {
LocalLLMModelManager.notifyCacheReady();
}).catch(error => {
console.error('[localLLM] Background cache write failed after inference creation.', error);
LocalLLMModelManager.notifyLoadError(error);
});
} else {
LocalLLMModelManager.notifyCacheReady();
}
return this.inference;
} catch (error) {
LocalLLMModelManager.notifyLoadError(error);
throw error;
}
}
private async getMediaPipeModule(): Promise<MediaPipeGenAI> {
return importMediaPipe();
}
private applyTemplate(message: { role: 'user' | 'model'; text: string }): string {
const template = PROMPT_TEMPLATE[message.role];
return `${template.pre}${message.text}${template.post}`;
}
private renderPrompt(
messages: Message[],
systemPrompt: string | undefined,
tools: OpenAIToolDefinition[] | undefined,
): string {
const thinkPrefix = '<|think|>';
const toolDeclarations = (tools ?? []).map(formatToolDeclaration).join('');
const systemContent = `${thinkPrefix}${systemPrompt ?? ''}${toolDeclarations}`;
const systemSection = systemContent
? `${PROMPT_TEMPLATE.system.pre}${systemContent}${PROMPT_TEMPLATE.system.post}`
: '';
const conversationParts: string[] = [];
for (let i = 0; i < messages.length; i += 1) {
const message = messages[i];
if (message.role === 'user') {
conversationParts.push(this.applyTemplate({ role: 'user', text: message.content ?? '' }));
continue;
}
if (message.role === 'assistant') {
let modelText = message.content ?? '';
if (message.tool_calls?.length) {
for (const toolCall of message.tool_calls) {
modelText += formatToolCall(toolCall.function.name, toolCall.function.arguments);
}
let scanIndex = i + 1;
while (scanIndex < messages.length && messages[scanIndex].role === 'tool') {
const toolMessage = messages[scanIndex];
const matchedCall = message.tool_calls.find(call => call.id === toolMessage.tool_call_id);
if (matchedCall) {
let parsedResult: unknown = toolMessage.content ?? '';
try {
parsedResult = JSON.parse(toolMessage.content ?? '{}');
} catch {
parsedResult = toolMessage.content ?? '';
}
modelText += formatToolResponse(matchedCall.function.name, parsedResult);
}
scanIndex += 1;
}
}
conversationParts.push(this.applyTemplate({ role: 'model', text: modelText }));
}
}
return `${systemSection}${conversationParts.join('')}${PROMPT_TEMPLATE.model.pre}`;
}
async *generateStream(
messages: Message[],
systemPrompt?: string,
tools?: OpenAIToolDefinition[],
): AsyncIterableIterator<StreamChunk> {
const inference = await this.ensureInference();
const prompt = this.renderPrompt(messages, systemPrompt, tools);
console.log('------------ LOCAL RAW PROMPT ------------');
console.log(prompt);
console.log('------------------------------------------');
const start = performance.now();
let firstTokenTime: number | null = null;
let rawResponse = '';
let streamedVisibleText = '';
const pendingTextDeltas: string[] = [];
let generationError: unknown = null;
let generationDone = false;
let notifyWaiting: (() => void) | null = null;
const wake = () => {
if (notifyWaiting) {
const resolve = notifyWaiting;
notifyWaiting = null;
resolve();
}
};
const generationPromise = new Promise<void>((resolve, reject) => {
try {
const result = inference.generateResponse(prompt, (partial, done) => {
if (firstTokenTime === null) {
firstTokenTime = performance.now();
}
rawResponse += partial;
const visibleText = stripToolProtocol(rawResponse);
if (visibleText.startsWith(streamedVisibleText)) {
const delta = visibleText.slice(streamedVisibleText.length);
if (delta) {
streamedVisibleText = visibleText;
pendingTextDeltas.push(delta);
wake();
}
} else if (visibleText && visibleText !== streamedVisibleText) {
const delta = visibleText.slice(streamedVisibleText.length) || visibleText;
streamedVisibleText = visibleText;
pendingTextDeltas.push(delta);
wake();
}
if (done) {
generationDone = true;
wake();
setTimeout(resolve, 0);
}
});
Promise.resolve(result).catch(error => {
generationError = error;
generationDone = true;
wake();
reject(error);
});
} catch (error) {
generationError = error;
generationDone = true;
wake();
reject(error);
}
});
while (!generationDone || pendingTextDeltas.length > 0) {
while (pendingTextDeltas.length > 0) {
const delta = pendingTextDeltas.shift();
if (delta) {
yield { type: 'text', content: delta };
}
}
if (generationDone) {
break;
}
await new Promise<void>(resolve => {
notifyWaiting = resolve;
});
}
await generationPromise;
if (generationError) {
throw generationError;
}
console.log('------------ LOCAL RAW RESPONSE ------------');
console.log(rawResponse);
console.log('--------------------------------------------');
const toolCalls = parseToolCalls(rawResponse);
const finishReason = toolCalls.length > 0 ? 'tool_calls' : 'stop';
const promptTokenCount = inference.sizeInTokens(prompt);
const generatedTokenCount = inference.sizeInTokens(rawResponse);
const totalEnd = performance.now();
const prefillMs = firstTokenTime !== null ? firstTokenTime - start : 0;
const decodeMs = Math.max(0, totalEnd - start - prefillMs);
const prefillTps = prefillMs > 0 ? promptTokenCount / (prefillMs / 1000) : 0;
const generationTps = decodeMs > 0 ? generatedTokenCount / (decodeMs / 1000) : 0;
console.log(`[localLLM] prefill t/s: ${prefillTps.toFixed(1)}`);
console.log(`[localLLM] generation t/s: ${generationTps.toFixed(1)}`);
for (const parsed of toolCalls) {
const toolCall: ToolCall = {
id: `gemma_tool_${Date.now()}_${Math.random().toString(36).slice(2)}`,
type: 'function',
function: {
name: parsed.name,
arguments: JSON.stringify(parsed.args),
},
};
yield { type: 'tool_call', content: '', toolCall };
}
yield { type: 'done', content: '', finishReason };
}
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { stripToolProtocol } from './gemmaToolProtocol';
describe('stripToolProtocol streaming safety', () => {
it('hides incomplete thought blocks entirely until they are closed', () => {
const partial = `<|channel>thought
I have successfully read the music.
I will summarize this for the user.`;
expect(stripToolProtocol(partial)).toBe('');
});
it('reveals visible answer cleanly after a thought block closes', () => {
const completed = `<|channel>thought
Internal reasoning here.
<channel|>Here is the sheet music I read.`;
expect(stripToolProtocol(completed)).toBe('Here is the sheet music I read.');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { parseToolCalls } from './gemmaToolProtocol';
describe('parseToolCalls', () => {
it('parses nested object arguments emitted by Gemma tool calling', () => {
const text = '<|tool_call>call:add_notes{notes:[{pitch:<|"|>C4<|"|>,meta:{velocity:90,length:1.5}}],replaceExisting:false}<tool_call|>';
const parsed = parseToolCalls(text);
expect(parsed).toHaveLength(1);
expect(parsed[0].name).toBe('add_notes');
expect(parsed[0].args).toEqual({
notes: [
{
pitch: 'C4',
meta: {
velocity: 90,
length: 1.5,
},
},
],
replaceExisting: false,
});
});
});
+154
View File
@@ -0,0 +1,154 @@
import type { OpenAIToolDefinition } from '../tools/BaseTool';
function gemmaValue(value: unknown): string {
if (typeof value === 'string') return `<|"|>${value}<|"|>`;
if (Array.isArray(value)) return `[${value.map(gemmaValue).join(',')}]`;
if (typeof value === 'object' && value !== null) {
return `{${Object.entries(value as Record<string, unknown>).map(([key, nested]) => `${key}:${gemmaValue(nested)}`).join(',')}}`;
}
return String(value);
}
export function formatToolDeclaration(tool: OpenAIToolDefinition): string {
const body = {
description: tool.function.description,
parameters: tool.function.parameters,
};
return `<|tool>declaration:${tool.function.name}${gemmaValue(body)}<tool|>`;
}
export function formatToolCall(name: string, args: string): string {
const parsed = JSON.parse(args) as Record<string, unknown>;
return `<|tool_call>call:${name}${gemmaValue(parsed)}<tool_call|>`;
}
export function formatToolResponse(name: string, result: unknown): string {
return `<|tool_response>response:${name}${gemmaValue(result)}<tool_response|>`;
}
function parseGemmaValue(str: string, pos: number): { value: unknown; next: number } {
while (pos < str.length && str[pos] === ' ') pos += 1;
const stringDelimiter = '<|"|>';
if (str.startsWith(stringDelimiter, pos)) {
const start = pos + stringDelimiter.length;
const end = str.indexOf(stringDelimiter, start);
if (end === -1) {
return { value: '', next: str.length };
}
return { value: str.slice(start, end), next: end + stringDelimiter.length };
}
if (str[pos] === '{') {
const result: Record<string, unknown> = {};
pos += 1;
while (pos < str.length && str[pos] !== '}') {
while (pos < str.length && (str[pos] === ',' || str[pos] === ' ')) pos += 1;
if (str[pos] === '}') break;
const colonIndex = str.indexOf(':', pos);
if (colonIndex === -1) break;
const key = str.slice(pos, colonIndex).trim();
pos = colonIndex + 1;
const nested = parseGemmaValue(str, pos);
result[key] = nested.value;
pos = nested.next;
}
return { value: result, next: pos + 1 };
}
if (str[pos] === '[') {
const result: unknown[] = [];
pos += 1;
while (pos < str.length && str[pos] !== ']') {
while (pos < str.length && (str[pos] === ',' || str[pos] === ' ')) pos += 1;
if (str[pos] === ']') break;
const nested = parseGemmaValue(str, pos);
result.push(nested.value);
pos = nested.next;
}
return { value: result, next: pos + 1 };
}
let end = pos;
while (end < str.length && str[end] !== ',' && str[end] !== '}' && str[end] !== ']') end += 1;
const raw = str.slice(pos, end).trim();
if (raw === 'true') return { value: true, next: end };
if (raw === 'false') return { value: false, next: end };
if (raw !== '' && !Number.isNaN(Number(raw))) return { value: Number(raw), next: end };
return { value: raw, next: end };
}
function parseArgs(argsStr: string): Record<string, unknown> {
const parsed = parseGemmaValue(`{${argsStr}}`, 0).value;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed as Record<string, unknown>;
}
export interface ParsedGemmaToolCall {
name: string;
args: Record<string, unknown>;
fullMatch: string;
index: number;
endIndex: number;
}
export function parseToolCalls(text: string): ParsedGemmaToolCall[] {
const prefix = '<|tool_call>call:';
const suffix = '<tool_call|>';
const stringDelimiter = '<|"|>';
const calls: ParsedGemmaToolCall[] = [];
let searchFrom = 0;
while (true) {
const prefixIndex = text.indexOf(prefix, searchFrom);
if (prefixIndex === -1) break;
const braceStart = text.indexOf('{', prefixIndex + prefix.length);
if (braceStart === -1) break;
const name = text.slice(prefixIndex + prefix.length, braceStart).trim();
let depth = 1;
let index = braceStart + 1;
while (index < text.length && depth > 0) {
if (text.startsWith(stringDelimiter, index)) {
const stringEnd = text.indexOf(stringDelimiter, index + stringDelimiter.length);
index = stringEnd === -1 ? text.length : stringEnd + stringDelimiter.length;
continue;
}
if (text[index] === '{') depth += 1;
else if (text[index] === '}') depth -= 1;
index += 1;
}
const braceEnd = index;
const suffixIndex = text.indexOf(suffix, braceEnd);
if (suffixIndex === -1) break;
const argsStr = text.slice(braceStart + 1, braceEnd - 1);
const endIndex = suffixIndex + suffix.length;
calls.push({
name,
args: parseArgs(argsStr),
fullMatch: text.slice(prefixIndex, endIndex),
index: prefixIndex,
endIndex,
});
searchFrom = endIndex;
}
return calls;
}
export function stripToolProtocol(text: string): string {
let result = text.replace(
/<\|tool_call>call:(\w+)\{[\s\S]*?\}<tool_call\|><\|tool_response>[\s\S]*?<tool_response\|>/g,
'[Tool call completed]',
);
result = result.replace(/<\|channel>thought[\s\S]*?<channel\|>/g, '');
result = result.replace(/<\|channel>thought[\s\S]*/g, '');
result = result.replace(/<\|tool_call>[\s\S]*/g, '');
result = result.replace(/<\|tool_response>[\s\S]*/g, '');
result = result.replace(/<\|"\|>/g, '');
return result.trimStart();
}