feat: implemented the v1 browser embedded LLM support (Gemma 4 E4B based)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user