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();
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import './ChatBox.css';
|
||||
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
|
||||
import { UserMessage, AssistantMessage } from './chat';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { LLMProvider } from '../agent/llm/LLMProvider';
|
||||
import { OpenAICompatibleLLMProvider, type LLMProvider } from '../agent/llm/LLMProvider';
|
||||
import { LocalBrowserLLMProvider } from '../agent/llm/LocalBrowserLLMProvider';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { SystemPrompts } from '../agent/core/SystemPrompts';
|
||||
@@ -13,6 +14,8 @@ import { useStreamProcessor } from '../hooks/useStreamProcessor';
|
||||
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
|
||||
import { formatLocalDateTime } from '../util/timeUtil';
|
||||
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
|
||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager';
|
||||
import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../util/localLLMConfig';
|
||||
import KGDropdown from './common/KGDropdown';
|
||||
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
@@ -32,6 +35,8 @@ const createLLMProviderFromConfig = (): LLMProvider => {
|
||||
let baseURL: string | undefined;
|
||||
|
||||
switch (providerType) {
|
||||
case LOCAL_LLM_PROVIDER_KEY:
|
||||
return new LocalBrowserLLMProvider();
|
||||
case 'openai':
|
||||
apiKey = configManager.get('general.openai.api_key') as string;
|
||||
model = configManager.get('general.openai.model') as string;
|
||||
@@ -50,7 +55,7 @@ const createLLMProviderFromConfig = (): LLMProvider => {
|
||||
break;
|
||||
}
|
||||
|
||||
return new LLMProvider(apiKey, model, baseURL);
|
||||
return new OpenAICompatibleLLMProvider(apiKey, model, baseURL);
|
||||
};
|
||||
|
||||
interface ChatBoxProps {
|
||||
@@ -64,6 +69,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||
const [activeProvider, setActiveProvider] = useState<string>('openai');
|
||||
|
||||
// Track if this is the first message (for system prompt logging)
|
||||
const [isFirstMessage, setIsFirstMessage] = useState(true);
|
||||
@@ -180,9 +187,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
}
|
||||
|
||||
const applyProviderFromConfig = () => {
|
||||
const providerType = (configManager.get('general.llm_provider') as string) || 'openai';
|
||||
const provider = createLLMProviderFromConfig();
|
||||
const agentCore = AgentCore.instance();
|
||||
agentCore.setLLMProvider(provider);
|
||||
setActiveProvider(providerType);
|
||||
console.log('LLM provider configured');
|
||||
};
|
||||
|
||||
@@ -204,6 +213,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
registerClearChatUICallback(clearChatUI);
|
||||
|
||||
const maybeUnsubscribePromise = initializeProvider();
|
||||
const unsubscribeLocalModel = LocalLLMModelManager.subscribe(setLocalModelState);
|
||||
|
||||
(async () => {
|
||||
if (hasShownWelcomeOnceInRuntime) return;
|
||||
@@ -216,6 +226,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unsubscribeLocalModel();
|
||||
Promise.resolve(maybeUnsubscribePromise).then((cleanup) => {
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
}).catch(() => {});
|
||||
@@ -266,7 +277,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
// Log system prompt only for first message
|
||||
if (isFirstMessage) {
|
||||
try {
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
const provider = AgentCore.instance().getLLMProvider();
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext(
|
||||
provider?.getPreferredSystemPromptPath?.(),
|
||||
);
|
||||
console.log('------------ SYSTEM ------------');
|
||||
console.log(systemPrompt);
|
||||
console.log('--------------------------------');
|
||||
@@ -372,6 +386,45 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeProvider === LOCAL_LLM_PROVIDER_KEY && (
|
||||
<div style={{ padding: '10px 14px 0 14px' }}>
|
||||
<div className="settings-group" style={{ marginBottom: '12px', padding: '14px' }}>
|
||||
<h4 style={{ marginBottom: '10px' }}>{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
|
||||
{!localModelState.runtimeSupport.supported && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginBottom: '8px' }}>
|
||||
{localModelState.runtimeSupport.reason}
|
||||
</div>
|
||||
)}
|
||||
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#b0b0b0', marginBottom: '8px' }}>
|
||||
The local language model has not been downloaded yet. It will be downloaded automatically the next time you send a chat request with this provider.
|
||||
</div>
|
||||
)}
|
||||
{(localModelState.isChecking || localModelState.isDownloading || localModelState.progressText) && (
|
||||
<div className="settings-progress-block">
|
||||
<div
|
||||
className="settings-progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.max(0, Math.min(100, localModelState.progressPercent))}
|
||||
>
|
||||
<div className="settings-progress-fill" style={{ width: `${Math.max(0, Math.min(100, localModelState.progressPercent))}%` }} />
|
||||
</div>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#b0b0b0', marginTop: '6px' }}>
|
||||
{localModelState.isChecking ? 'Checking local model cache...' : localModelState.progressText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{localModelState.error && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#d45a5a' }}>
|
||||
{localModelState.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chatbox-messages">
|
||||
{messages.map((message) => (
|
||||
message.role === 'user' ? (
|
||||
|
||||
@@ -293,6 +293,25 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-progress-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.settings-progress-track {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
.settings-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #5a9fd4 0%, #76c28f 100%);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Settings Help Links */
|
||||
.settings-help-links {
|
||||
display: flex;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
||||
import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../../../util/localLLMConfig';
|
||||
|
||||
const GeneralSettings: React.FC = () => {
|
||||
const [llmProvider, setLlmProvider] = useState<string>('openai');
|
||||
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
|
||||
const [openaiKey, setOpenaiKey] = useState<string>('');
|
||||
const [openaiModel, setOpenaiModel] = useState<string>('');
|
||||
const [geminiKey, setGeminiKey] = useState<string>('');
|
||||
@@ -22,6 +24,7 @@ const GeneralSettings: React.FC = () => {
|
||||
const [kgoneBaseUrl, setKgoneBaseUrl] = useState<string>('');
|
||||
const [kgoneServerManaged, setKgoneServerManaged] = useState<boolean>(false);
|
||||
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
|
||||
@@ -46,7 +49,7 @@ const GeneralSettings: React.FC = () => {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
setLlmProvider((configManager.get('general.llm_provider') as string) || 'openai');
|
||||
setLlmProvider((configManager.get('general.llm_provider') as string) || LOCAL_LLM_PROVIDER_KEY);
|
||||
setOpenaiKey((configManager.get('general.openai.api_key') as string) || '');
|
||||
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
|
||||
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
|
||||
@@ -69,6 +72,8 @@ const GeneralSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
||||
return unsubscribe;
|
||||
}, [configManager]);
|
||||
|
||||
// Debounced save function for text inputs
|
||||
@@ -197,6 +202,14 @@ const GeneralSettings: React.FC = () => {
|
||||
debouncedSave('general.kgone.base_url', value);
|
||||
};
|
||||
|
||||
const handleDeleteLocalModel = async () => {
|
||||
try {
|
||||
await LocalLLMModelManager.deleteCachedModel();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete local language model cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// NOTE: Gemini and Claude are not supported yet due to CORS issues.
|
||||
return (
|
||||
<div className="settings-section">
|
||||
@@ -217,6 +230,7 @@ const GeneralSettings: React.FC = () => {
|
||||
value={llmProvider}
|
||||
onChange={(e) => handleLlmProviderChange(e.target.value)}
|
||||
>
|
||||
<option value={LOCAL_LLM_PROVIDER_KEY}>Local LLM (Browser)</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
{/* <option value="gemini">Gemini</option>
|
||||
<option value="claude">Claude</option> */}
|
||||
@@ -243,6 +257,69 @@ const GeneralSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
|
||||
|
||||
{!localModelState.runtimeSupport.supported && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '4px', marginBottom: '8px' }}>
|
||||
{localModelState.runtimeSupport.reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Cached Model Status
|
||||
</label>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{localModelState.isChecking
|
||||
? 'Checking local model cache...'
|
||||
: localModelState.isCached
|
||||
? 'Downloaded in browser cache.'
|
||||
: 'Not downloaded yet.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
|
||||
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(localModelState.isDownloading || localModelState.progressText) && (
|
||||
<div className="settings-progress-block">
|
||||
<div
|
||||
className="settings-progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.max(0, Math.min(100, localModelState.progressPercent))}
|
||||
>
|
||||
<div className="settings-progress-fill" style={{ width: `${Math.max(0, Math.min(100, localModelState.progressPercent))}%` }} />
|
||||
</div>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '6px' }}>
|
||||
{localModelState.progressText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localModelState.error && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#d45a5a', marginTop: '8px' }}>
|
||||
{localModelState.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-item" style={{ marginTop: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-danger"
|
||||
onClick={() => void handleDeleteLocalModel()}
|
||||
disabled={localModelState.isDeleting || localModelState.isDownloading || !localModelState.isCached}
|
||||
>
|
||||
{localModelState.isDeleting ? 'Deleting...' : 'Delete Cached Model'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>OpenAI</h4>
|
||||
|
||||
@@ -600,4 +677,4 @@ const GeneralSettings: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
export default GeneralSettings;
|
||||
|
||||
@@ -104,7 +104,7 @@ export const OPFS_CONSTANTS = {
|
||||
|
||||
export const CONFIG_UPGRADER_CONSTANTS = {
|
||||
VERSION_KEY: '__config_version',
|
||||
CURRENT_VERSION: 1,
|
||||
CURRENT_VERSION: 2,
|
||||
};
|
||||
|
||||
export const URL_CONSTANTS = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||
import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
||||
import { upgradeConfigToV2 } from './upgradeConfigToV2';
|
||||
|
||||
/**
|
||||
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
|
||||
@@ -33,6 +34,10 @@ export class KGConfigUpgrader {
|
||||
await upgradeConfigToV1();
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
await upgradeConfigToV2();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`No config upgrader found for version ${nextVersion}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const configStore = new Map<string, { name: string; data: Record<string, unknown>; lastModified: number }>();
|
||||
|
||||
vi.mock('../io/KGConfigStorage', () => ({
|
||||
KGConfigStorage: {
|
||||
getInstance: () => ({
|
||||
getRaw: vi.fn(async (name: string) => configStore.get(name)?.data ?? null),
|
||||
saveRaw: vi.fn(async (name: string, data: Record<string, unknown>) => {
|
||||
configStore.set(name, { name, data, lastModified: Date.now() });
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { upgradeConfigToV2 } from './upgradeConfigToV2';
|
||||
|
||||
describe('upgradeConfigToV2', () => {
|
||||
beforeEach(() => {
|
||||
configStore.clear();
|
||||
});
|
||||
|
||||
it('pins legacy installs without an explicit provider to the old default provider', async () => {
|
||||
configStore.set('userConfig', {
|
||||
name: 'userConfig',
|
||||
data: {
|
||||
general: {
|
||||
openai: { api_key: '', model: 'gpt-5.4-mini', flex: false },
|
||||
},
|
||||
},
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
await upgradeConfigToV2();
|
||||
|
||||
expect((configStore.get('userConfig')?.data.general as Record<string, unknown>).llm_provider).toBe('openai');
|
||||
});
|
||||
|
||||
it('leaves explicit providers unchanged', async () => {
|
||||
configStore.set('userConfig', {
|
||||
name: 'userConfig',
|
||||
data: {
|
||||
general: {
|
||||
llm_provider: 'openai_compatible',
|
||||
},
|
||||
},
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
await upgradeConfigToV2();
|
||||
|
||||
expect((configStore.get('userConfig')?.data.general as Record<string, unknown>).llm_provider).toBe('openai_compatible');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||
|
||||
const CONFIG_KEY = 'userConfig';
|
||||
const LEGACY_DEFAULT_PROVIDER = 'openai';
|
||||
|
||||
export async function upgradeConfigToV2(): Promise<void> {
|
||||
const storage = KGConfigStorage.getInstance();
|
||||
const rawConfig = await storage.getRaw(CONFIG_KEY);
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
const general = config.general;
|
||||
if (!general || typeof general !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('llm_provider' in (general as Record<string, unknown>)) {
|
||||
return;
|
||||
}
|
||||
|
||||
(general as Record<string, unknown>).llm_provider = LEGACY_DEFAULT_PROVIDER;
|
||||
await storage.saveRaw(CONFIG_KEY, config);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||
interface AppConfig {
|
||||
general: {
|
||||
language: string;
|
||||
llm_provider: 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
|
||||
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
|
||||
persist_api_keys_non_localhost: boolean;
|
||||
openai: {
|
||||
api_key: string;
|
||||
@@ -183,7 +183,7 @@ export class ConfigManager {
|
||||
this.defaultConfig = {
|
||||
general: {
|
||||
language: 'en_us',
|
||||
llm_provider: 'openai',
|
||||
llm_provider: 'local_browser',
|
||||
persist_api_keys_non_localhost: false,
|
||||
openai: {
|
||||
api_key: '',
|
||||
|
||||
@@ -9,10 +9,12 @@ class MockWritableFileStream {
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
async write(content: ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const bytes = content instanceof ArrayBuffer
|
||||
? new Uint8Array(content)
|
||||
: new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
||||
async write(content: ArrayBuffer | ArrayBufferView | string): Promise<void> {
|
||||
const bytes = typeof content === 'string'
|
||||
? new TextEncoder().encode(content)
|
||||
: content instanceof ArrayBuffer
|
||||
? new Uint8Array(content)
|
||||
: new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
||||
this.chunks.push(new Uint8Array(bytes));
|
||||
}
|
||||
|
||||
@@ -44,6 +46,8 @@ class MockFileSystemFileHandle {
|
||||
|
||||
async getFile(): Promise<File> {
|
||||
return {
|
||||
size: this.content.byteLength,
|
||||
text: async () => new TextDecoder().decode(this.content),
|
||||
arrayBuffer: async () => this.content.buffer.slice(0),
|
||||
} as unknown as File;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export const LOCAL_LLM_PROVIDER_KEY = 'local_browser';
|
||||
export const LOCAL_LLM_MODEL_URL =
|
||||
'http://localhost:3000/models/gemma-4-E4B-it-web.task';
|
||||
export const LOCAL_LLM_MODEL_FILENAME = 'gemma-4-E4B-it-web.task';
|
||||
export const LOCAL_LLM_DISPLAY_NAME = 'Gemma 4 E4B';
|
||||
export const LOCAL_LLM_LEGACY_FILENAMES = [
|
||||
'gemma-3n-E4B-it-int4-Web.litertlm',
|
||||
];
|
||||
|
||||
export interface LocalLLMRuntimeSupport {
|
||||
supported: boolean;
|
||||
webgpuExposed: boolean;
|
||||
crossOriginIsolated: boolean;
|
||||
sharedArrayBufferAvailable: boolean;
|
||||
secureContext: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export function detectLocalLLMRuntimeSupport(): LocalLLMRuntimeSupport {
|
||||
const secureContext = typeof window !== 'undefined' ? window.isSecureContext : false;
|
||||
const crossOriginIsolated = typeof window !== 'undefined' ? window.crossOriginIsolated : false;
|
||||
const sharedArrayBufferAvailable = typeof SharedArrayBuffer !== 'undefined';
|
||||
const webgpuExposed = typeof navigator !== 'undefined' && 'gpu' in navigator;
|
||||
|
||||
let reason: string | null = null;
|
||||
if (!secureContext) {
|
||||
reason = 'Local browser LLM requires a secure context (HTTPS or localhost).';
|
||||
} else if (!crossOriginIsolated || !sharedArrayBufferAvailable) {
|
||||
reason = 'Local browser LLM requires SharedArrayBuffer support. Ensure COOP/COEP headers are enabled.';
|
||||
} else if (!webgpuExposed) {
|
||||
reason = 'Local browser LLM currently requires a browser with WebGPU support.';
|
||||
}
|
||||
|
||||
return {
|
||||
supported: reason === null,
|
||||
webgpuExposed,
|
||||
crossOriginIsolated,
|
||||
sharedArrayBufferAvailable,
|
||||
secureContext,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache';
|
||||
import { LOCAL_LLM_MODEL_FILENAME } from './localLLMConfig';
|
||||
|
||||
const cache = new OpfsModelCache({ directoryName: 'models' });
|
||||
let writingToCachePromise: Promise<void> | null = null;
|
||||
|
||||
export { type ModelDownloadProgress };
|
||||
|
||||
export interface CachedModelStreamResult {
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
totalBytes: number;
|
||||
fromCache: boolean;
|
||||
cacheWritePromise: Promise<void> | null;
|
||||
}
|
||||
|
||||
export class LocalLLMModelCache {
|
||||
public static async exists(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<boolean> {
|
||||
return cache.exists(filename);
|
||||
}
|
||||
|
||||
public static async getFile(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<File> {
|
||||
return cache.getFile(filename);
|
||||
}
|
||||
|
||||
public static async getArrayBuffer(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<ArrayBuffer> {
|
||||
return cache.getArrayBuffer(filename);
|
||||
}
|
||||
|
||||
public static async delete(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<void> {
|
||||
await cache.delete(filename);
|
||||
}
|
||||
|
||||
public static async loadModelReaderWithCache(
|
||||
sourceUrl: string,
|
||||
filename: string = LOCAL_LLM_MODEL_FILENAME,
|
||||
onProgress?: (progress: ModelDownloadProgress & { fromCache: boolean }) => void,
|
||||
): Promise<CachedModelStreamResult> {
|
||||
if (writingToCachePromise) {
|
||||
await writingToCachePromise.catch(() => {});
|
||||
}
|
||||
|
||||
if (await this.exists(filename)) {
|
||||
const file = await this.getFile(filename);
|
||||
onProgress?.({
|
||||
receivedBytes: file.size,
|
||||
totalBytes: file.size,
|
||||
percent: 100,
|
||||
fromCache: true,
|
||||
});
|
||||
return {
|
||||
reader: file.stream().getReader(),
|
||||
totalBytes: file.size,
|
||||
fromCache: true,
|
||||
cacheWritePromise: null,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(sourceUrl);
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Model download failed (${response.status})`);
|
||||
}
|
||||
|
||||
const totalBytesHeader = response.headers.get('Content-Length');
|
||||
const totalBytes = totalBytesHeader ? Number(totalBytesHeader) : 0;
|
||||
const [streamForConsumer, streamForCache] = response.body.tee();
|
||||
|
||||
writingToCachePromise = cache.downloadStream(
|
||||
streamForCache,
|
||||
filename,
|
||||
totalBytes > 0 ? totalBytes : null,
|
||||
progress => onProgress?.({ ...progress, fromCache: false }),
|
||||
);
|
||||
writingToCachePromise = writingToCachePromise.finally(() => {
|
||||
writingToCachePromise = null;
|
||||
});
|
||||
|
||||
return {
|
||||
reader: streamForConsumer.getReader(),
|
||||
totalBytes,
|
||||
fromCache: false,
|
||||
cacheWritePromise: writingToCachePromise,
|
||||
};
|
||||
}
|
||||
|
||||
public static async download(
|
||||
sourceUrl: string,
|
||||
filename: string = LOCAL_LLM_MODEL_FILENAME,
|
||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||
): Promise<void> {
|
||||
await cache.download(sourceUrl, filename, onProgress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
detectLocalLLMRuntimeSupport,
|
||||
LOCAL_LLM_LEGACY_FILENAMES,
|
||||
LOCAL_LLM_MODEL_FILENAME,
|
||||
LOCAL_LLM_MODEL_URL,
|
||||
type LocalLLMRuntimeSupport,
|
||||
} from './localLLMConfig';
|
||||
import { LocalLLMModelCache } from './localLLMModelCache';
|
||||
|
||||
export interface LocalLLMModelState {
|
||||
isCached: boolean;
|
||||
isChecking: boolean;
|
||||
isDownloading: boolean;
|
||||
isDeleting: boolean;
|
||||
progressPercent: number;
|
||||
progressText: string;
|
||||
error: string;
|
||||
runtimeSupport: LocalLLMRuntimeSupport;
|
||||
}
|
||||
|
||||
type Listener = (state: LocalLLMModelState) => void;
|
||||
|
||||
export class LocalLLMModelManager {
|
||||
private static listeners = new Set<Listener>();
|
||||
private static initialized = false;
|
||||
private static state: LocalLLMModelState = {
|
||||
isCached: false,
|
||||
isChecking: false,
|
||||
isDownloading: false,
|
||||
isDeleting: false,
|
||||
progressPercent: 0,
|
||||
progressText: '',
|
||||
error: '',
|
||||
runtimeSupport: detectLocalLLMRuntimeSupport(),
|
||||
};
|
||||
|
||||
public static subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getState());
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
void this.refresh();
|
||||
}
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public static getState(): LocalLLMModelState {
|
||||
return { ...this.state, runtimeSupport: { ...this.state.runtimeSupport } };
|
||||
}
|
||||
|
||||
public static async refresh(): Promise<void> {
|
||||
this.setState({
|
||||
isChecking: true,
|
||||
runtimeSupport: detectLocalLLMRuntimeSupport(),
|
||||
});
|
||||
try {
|
||||
await this.cleanupLegacyEntries();
|
||||
const isCached = await LocalLLMModelCache.exists();
|
||||
this.setState({ isCached, error: '' });
|
||||
} catch (error) {
|
||||
this.setState({ error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
this.setState({ isChecking: false });
|
||||
}
|
||||
}
|
||||
|
||||
public static async ensureRuntimeSupported(): Promise<void> {
|
||||
const runtimeSupport = detectLocalLLMRuntimeSupport();
|
||||
this.setState({ runtimeSupport });
|
||||
if (!runtimeSupport.supported) {
|
||||
throw new Error(runtimeSupport.reason ?? 'Local browser LLM is not supported in this browser.');
|
||||
}
|
||||
|
||||
await this.cleanupLegacyEntries();
|
||||
}
|
||||
|
||||
public static async deleteCachedModel(): Promise<void> {
|
||||
this.setState({ isDeleting: true, error: '' });
|
||||
try {
|
||||
await LocalLLMModelCache.delete();
|
||||
await this.cleanupLegacyEntries();
|
||||
this.setState({
|
||||
isCached: false,
|
||||
progressPercent: 0,
|
||||
progressText: '',
|
||||
});
|
||||
} catch (error) {
|
||||
this.setState({ error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
} finally {
|
||||
this.setState({ isDeleting: false });
|
||||
}
|
||||
}
|
||||
|
||||
private static setState(partial: Partial<LocalLLMModelState>): void {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...partial,
|
||||
};
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.getState());
|
||||
}
|
||||
}
|
||||
|
||||
private static async cleanupLegacyEntries(): Promise<void> {
|
||||
await Promise.all(
|
||||
LOCAL_LLM_LEGACY_FILENAMES.map(async legacyFilename => {
|
||||
try {
|
||||
await LocalLLMModelCache.delete(legacyFilename);
|
||||
} catch {
|
||||
// Ignore best-effort legacy cleanup failures.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public static notifyLoadStart(fromCache: boolean): void {
|
||||
this.setState({
|
||||
isDownloading: !fromCache,
|
||||
progressPercent: fromCache ? 100 : 0,
|
||||
progressText: fromCache ? 'Loading local language model from browser cache...' : 'Downloading local language model...',
|
||||
error: '',
|
||||
});
|
||||
}
|
||||
|
||||
public static notifyLoadProgress(receivedBytes: number, totalBytes: number | null, fromCache: boolean): void {
|
||||
const receivedMb = (receivedBytes / (1024 * 1024)).toFixed(1);
|
||||
const totalMb = totalBytes ? (totalBytes / (1024 * 1024)).toFixed(1) : null;
|
||||
this.setState({
|
||||
isDownloading: !fromCache,
|
||||
progressPercent: totalBytes ? (receivedBytes / totalBytes) * 100 : 0,
|
||||
progressText: fromCache
|
||||
? 'Loading local language model from browser cache...'
|
||||
: totalMb
|
||||
? `Downloading local language model... ${receivedMb} / ${totalMb} MB`
|
||||
: `Downloading local language model... ${receivedMb} MB`,
|
||||
error: '',
|
||||
});
|
||||
}
|
||||
|
||||
public static notifyCacheReady(): void {
|
||||
this.setState({
|
||||
isCached: true,
|
||||
isDownloading: false,
|
||||
progressPercent: 100,
|
||||
progressText: 'Local language model is ready.',
|
||||
error: '',
|
||||
});
|
||||
}
|
||||
|
||||
public static notifyLoadError(error: unknown): void {
|
||||
this.setState({
|
||||
isDownloading: false,
|
||||
progressPercent: 0,
|
||||
progressText: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,25 @@
|
||||
import { LOCAL_SEPARATOR_MODEL_FILENAME } from './localSeparatorConfig';
|
||||
import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache';
|
||||
|
||||
export interface ModelDownloadProgress {
|
||||
receivedBytes: number;
|
||||
totalBytes: number | null;
|
||||
percent: number;
|
||||
}
|
||||
const cache = new OpfsModelCache({ directoryName: 'models' });
|
||||
|
||||
export { type ModelDownloadProgress };
|
||||
|
||||
export class LocalSeparatorModelCache {
|
||||
private static readonly MODELS_DIR = 'models';
|
||||
private static readonly TEMP_SUFFIX = '.download';
|
||||
|
||||
public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<boolean> {
|
||||
try {
|
||||
const modelsDir = await this.getModelsDir();
|
||||
await modelsDir.getFileHandle(filename);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return cache.exists(filename);
|
||||
}
|
||||
|
||||
public static async getFile(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<File> {
|
||||
const modelsDir = await this.getModelsDir();
|
||||
const fileHandle = await modelsDir.getFileHandle(filename);
|
||||
return fileHandle.getFile();
|
||||
return cache.getFile(filename);
|
||||
}
|
||||
|
||||
public static async getArrayBuffer(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<ArrayBuffer> {
|
||||
const file = await this.getFile(filename);
|
||||
return file.arrayBuffer();
|
||||
return cache.getArrayBuffer(filename);
|
||||
}
|
||||
|
||||
public static async delete(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<void> {
|
||||
try {
|
||||
const modelsDir = await this.getModelsDir();
|
||||
await modelsDir.removeEntry(filename);
|
||||
} catch {
|
||||
// Ignore missing file cleanup.
|
||||
}
|
||||
|
||||
try {
|
||||
const modelsDir = await this.getModelsDir();
|
||||
await modelsDir.removeEntry(`${filename}${this.TEMP_SUFFIX}`);
|
||||
} catch {
|
||||
// Ignore missing temp file cleanup.
|
||||
}
|
||||
await cache.delete(filename);
|
||||
}
|
||||
|
||||
public static async download(
|
||||
@@ -52,75 +27,6 @@ export class LocalSeparatorModelCache {
|
||||
filename: string = LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||
): Promise<void> {
|
||||
const response = await fetch(sourceUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Model download failed (${response.status})`);
|
||||
}
|
||||
|
||||
const modelsDir = await this.getModelsDir();
|
||||
const tempName = `${filename}${this.TEMP_SUFFIX}`;
|
||||
await this.delete(filename);
|
||||
|
||||
const tempHandle = await modelsDir.getFileHandle(tempName, { create: true });
|
||||
const writable = await tempHandle.createWritable();
|
||||
|
||||
try {
|
||||
const totalBytesHeader = response.headers.get('Content-Length');
|
||||
const totalBytes = totalBytesHeader ? Number(totalBytesHeader) : null;
|
||||
|
||||
if (!response.body) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
await writable.write(buffer);
|
||||
onProgress?.({
|
||||
receivedBytes: buffer.byteLength,
|
||||
totalBytes,
|
||||
percent: 100,
|
||||
});
|
||||
} else {
|
||||
const reader = response.body.getReader();
|
||||
let receivedBytes = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
await writable.write(value);
|
||||
receivedBytes += value.byteLength;
|
||||
onProgress?.({
|
||||
receivedBytes,
|
||||
totalBytes,
|
||||
percent: totalBytes ? (receivedBytes / totalBytes) * 100 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await writable.abort();
|
||||
await this.delete(filename);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
|
||||
const finalHandle = await modelsDir.getFileHandle(filename, { create: true });
|
||||
const finalWritable = await finalHandle.createWritable();
|
||||
try {
|
||||
const tempFile = await tempHandle.getFile();
|
||||
await finalWritable.write(await tempFile.arrayBuffer());
|
||||
await finalWritable.close();
|
||||
} catch (error) {
|
||||
await finalWritable.abort();
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await modelsDir.removeEntry(tempName);
|
||||
} catch {
|
||||
// Ignore temp cleanup errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async getModelsDir(): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
return root.getDirectoryHandle(this.MODELS_DIR, { create: true });
|
||||
await cache.download(sourceUrl, filename, onProgress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { clearChatHistoryAndUI } from '../chatUtil';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { SystemPrompts } from '../../agent/core/SystemPrompts';
|
||||
import { detectLocalLLMRuntimeSupport, LOCAL_LLM_PROVIDER_KEY } from '../localLLMConfig';
|
||||
|
||||
export interface UserMessageFilterResult {
|
||||
// Whether to render the user message bubble (div.message-user)
|
||||
@@ -134,7 +135,18 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
}
|
||||
const provider = (configManager.get('general.llm_provider') as string) || 'openai';
|
||||
|
||||
if (provider === 'openai') {
|
||||
if (provider === LOCAL_LLM_PROVIDER_KEY) {
|
||||
const runtimeSupport = detectLocalLLMRuntimeSupport();
|
||||
if (!runtimeSupport.supported) {
|
||||
return {
|
||||
displayUserMessage: true,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: runtimeSupport.reason ?? 'Local browser LLM is not supported in this environment.',
|
||||
metadata: { error: 'local_browser_unsupported' }
|
||||
};
|
||||
}
|
||||
} else if (provider === 'openai') {
|
||||
const openaiKey = (configManager.get('general.openai.api_key') as string) || '';
|
||||
if (openaiKey.trim() === '') {
|
||||
const url = `${import.meta.env.BASE_URL}chat/error_no_openai_key.md`;
|
||||
@@ -256,4 +268,3 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
export interface ModelDownloadProgress {
|
||||
receivedBytes: number;
|
||||
totalBytes: number | null;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
interface OpfsModelCacheOptions {
|
||||
directoryName?: string;
|
||||
sizeSuffix?: string;
|
||||
tempSuffix?: string;
|
||||
}
|
||||
|
||||
export class OpfsModelCache {
|
||||
private readonly directoryName: string;
|
||||
private readonly sizeSuffix: string;
|
||||
private readonly tempSuffix: string;
|
||||
|
||||
constructor(options: OpfsModelCacheOptions = {}) {
|
||||
this.directoryName = options.directoryName ?? 'models';
|
||||
this.sizeSuffix = options.sizeSuffix ?? '.size';
|
||||
this.tempSuffix = options.tempSuffix ?? '.download';
|
||||
}
|
||||
|
||||
public async exists(filename: string): Promise<boolean> {
|
||||
try {
|
||||
const dir = await this.getDir();
|
||||
const fileHandle = await dir.getFileHandle(filename);
|
||||
const sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename));
|
||||
const [file, sizeFile] = await Promise.all([fileHandle.getFile(), sizeHandle.getFile()]);
|
||||
const expectedSize = Number(await sizeFile.text());
|
||||
if (!Number.isFinite(expectedSize) || expectedSize <= 0) {
|
||||
await this.delete(filename);
|
||||
return false;
|
||||
}
|
||||
if (file.size !== expectedSize) {
|
||||
await this.delete(filename);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getFile(filename: string): Promise<File> {
|
||||
const dir = await this.getDir();
|
||||
const handle = await dir.getFileHandle(filename);
|
||||
const file = await handle.getFile();
|
||||
console.log('[opfsModelCache] Opened cached file.', {
|
||||
filename,
|
||||
size: file.size,
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
public async getArrayBuffer(filename: string): Promise<ArrayBuffer> {
|
||||
const file = await this.getFile(filename);
|
||||
return file.arrayBuffer();
|
||||
}
|
||||
|
||||
public async delete(filename: string): Promise<void> {
|
||||
const dir = await this.getDir();
|
||||
await this.removeIfExists(dir, filename);
|
||||
await this.removeIfExists(dir, this.getSizeFilename(filename));
|
||||
await this.removeIfExists(dir, `${filename}${this.tempSuffix}`);
|
||||
await this.removeIfExists(dir, `${this.getSizeFilename(filename)}${this.tempSuffix}`);
|
||||
}
|
||||
|
||||
public async download(
|
||||
sourceUrl: string,
|
||||
filename: string,
|
||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||
): Promise<void> {
|
||||
const response = await fetch(sourceUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Model download failed (${response.status})`);
|
||||
}
|
||||
const totalBytesHeader = response.headers.get('Content-Length');
|
||||
const totalBytes = totalBytesHeader ? Number(totalBytesHeader) : null;
|
||||
if (!response.body) {
|
||||
throw new Error('Model download response did not include a readable body.');
|
||||
}
|
||||
await this.downloadStream(response.body, filename, totalBytes, onProgress);
|
||||
}
|
||||
|
||||
public async downloadStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
filename: string,
|
||||
totalBytes: number | null,
|
||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||
): Promise<void> {
|
||||
const dir = await this.getDir();
|
||||
await this.delete(filename);
|
||||
|
||||
const tempFilename = `${filename}${this.tempSuffix}`;
|
||||
const tempHandle = await dir.getFileHandle(tempFilename, { create: true });
|
||||
const tempWritable = await tempHandle.createWritable();
|
||||
const reader = stream.getReader();
|
||||
let receivedBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
await tempWritable.write(value);
|
||||
receivedBytes += value.byteLength;
|
||||
onProgress?.({
|
||||
receivedBytes,
|
||||
totalBytes,
|
||||
percent: totalBytes ? (receivedBytes / totalBytes) * 100 : 0,
|
||||
});
|
||||
}
|
||||
await tempWritable.close();
|
||||
|
||||
const sizeValue = totalBytes ?? receivedBytes;
|
||||
if (!Number.isFinite(sizeValue) || sizeValue <= 0) {
|
||||
throw new Error('Model download did not provide a valid size.');
|
||||
}
|
||||
|
||||
console.log(`[opfsModelCache] Finalizing cached model ${filename} from temp file ${tempFilename}.`);
|
||||
const finalHandle = await dir.getFileHandle(filename, { create: true });
|
||||
const finalWritable = await finalHandle.createWritable();
|
||||
try {
|
||||
const tempFile = await tempHandle.getFile();
|
||||
console.log('[opfsModelCache] Temp file ready for finalize copy.', {
|
||||
filename,
|
||||
tempFilename,
|
||||
tempSize: tempFile.size,
|
||||
expectedSize: sizeValue,
|
||||
});
|
||||
await finalWritable.write(tempFile);
|
||||
await finalWritable.close();
|
||||
} catch (error) {
|
||||
await finalWritable.abort();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename), { create: true });
|
||||
const sizeWritable = await sizeHandle.createWritable();
|
||||
try {
|
||||
await sizeWritable.write(String(sizeValue));
|
||||
await sizeWritable.close();
|
||||
} catch (error) {
|
||||
await sizeWritable.abort();
|
||||
throw error;
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
receivedBytes: sizeValue,
|
||||
totalBytes: sizeValue,
|
||||
percent: 100,
|
||||
});
|
||||
console.log('[opfsModelCache] Cached model finalize completed.', {
|
||||
filename,
|
||||
size: sizeValue,
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await tempWritable.abort();
|
||||
} catch {
|
||||
// Ignore abort cleanup errors.
|
||||
}
|
||||
await this.delete(filename);
|
||||
throw error;
|
||||
} finally {
|
||||
await this.removeIfExists(dir, tempFilename);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
private getSizeFilename(filename: string): string {
|
||||
return `${filename}${this.sizeSuffix}`;
|
||||
}
|
||||
|
||||
private async getDir(): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
return root.getDirectoryHandle(this.directoryName, { create: true });
|
||||
}
|
||||
|
||||
private async removeIfExists(dir: FileSystemDirectoryHandle, name: string): Promise<void> {
|
||||
try {
|
||||
await dir.removeEntry(name);
|
||||
} catch {
|
||||
// Ignore missing entry cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user