Merge pull request #43 from KGAudioLab/feat/2026-05-11-embedded-models

fix: allow local Gemma runtime on GitHub Pages and avoid OPFS cache f…
This commit is contained in:
Xiaohan-Tian
2026-05-15 13:04:56 -07:00
committed by GitHub
5 changed files with 23 additions and 47 deletions
-6
View File
@@ -341,7 +341,6 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const localRuntimeMessage = localModelState.runtimeSupport.reason; const localRuntimeMessage = localModelState.runtimeSupport.reason;
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported; const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage;
return ( return (
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}> <div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
@@ -395,11 +394,6 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div className="chatbox-local-runtime-section"> <div className="chatbox-local-runtime-section">
<div className="chatbox-local-runtime-card"> <div className="chatbox-local-runtime-card">
<h4 className="chatbox-local-mode-title">{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4> <h4 className="chatbox-local-mode-title">{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
{hasLocalRuntimeWarning && (
<div className="chatbox-local-runtime-warning">
{localRuntimeMessage}
</div>
)}
{hasLocalRuntimeHardFailure && ( {hasLocalRuntimeHardFailure && (
<div className="chatbox-local-runtime-error"> <div className="chatbox-local-runtime-error">
{localRuntimeMessage} {localRuntimeMessage}
@@ -119,7 +119,7 @@ describe('GeneralSettings', () => {
}); });
}); });
it('shows a warning when runtime may fail on this host but is still allowed', async () => { it('keeps local runtime available when runtime may fail on this host', async () => {
localModelState.runtimeSupport = { localModelState.runtimeSupport = {
supported: true, supported: true,
webgpuExposed: true, webgpuExposed: true,
@@ -131,7 +131,8 @@ describe('GeneralSettings', () => {
render(<GeneralSettings />); render(<GeneralSettings />);
expect(await screen.findByText(/may not support the local browser runtime reliably/i)).toBeTruthy(); expect(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.queryByText(/may not support the local browser runtime reliably/i)).toBeNull();
expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy(); expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy();
}); });
}); });
@@ -234,7 +234,6 @@ const GeneralSettings: React.FC = () => {
const localRuntimeMessage = localModelState.runtimeSupport.reason; const localRuntimeMessage = localModelState.runtimeSupport.reason;
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported; const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage;
// NOTE: Gemini and Claude are not supported yet due to CORS issues. // NOTE: Gemini and Claude are not supported yet due to CORS issues.
return ( return (
@@ -286,12 +285,6 @@ const GeneralSettings: React.FC = () => {
<div className="settings-group"> <div className="settings-group">
<h4>{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4> <h4>{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
{hasLocalRuntimeWarning && (
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '4px', marginBottom: '8px' }}>
{localRuntimeMessage}
</div>
)}
{hasLocalRuntimeHardFailure && ( {hasLocalRuntimeHardFailure && (
<div className="settings-help" style={{ fontSize: '12px', color: '#d45a5a', marginTop: '4px', marginBottom: '8px' }}> <div className="settings-help" style={{ fontSize: '12px', color: '#d45a5a', marginTop: '4px', marginBottom: '8px' }}>
{localRuntimeMessage} {localRuntimeMessage}
+15 -1
View File
@@ -49,9 +49,11 @@ export class LocalLLMModelManager {
} }
public static async refresh(): Promise<void> { public static async refresh(): Promise<void> {
const runtimeSupport = detectLocalLLMRuntimeSupport();
this.logSoftRuntimeWarning(runtimeSupport);
this.setState({ this.setState({
isChecking: true, isChecking: true,
runtimeSupport: detectLocalLLMRuntimeSupport(), runtimeSupport,
}); });
try { try {
await this.cleanupLegacyEntries(); await this.cleanupLegacyEntries();
@@ -66,6 +68,7 @@ export class LocalLLMModelManager {
public static async ensureRuntimeSupported(): Promise<void> { public static async ensureRuntimeSupported(): Promise<void> {
const runtimeSupport = detectLocalLLMRuntimeSupport(); const runtimeSupport = detectLocalLLMRuntimeSupport();
this.logSoftRuntimeWarning(runtimeSupport);
this.setState({ runtimeSupport }); this.setState({ runtimeSupport });
if (!runtimeSupport.supported) { if (!runtimeSupport.supported) {
throw new Error(runtimeSupport.reason ?? 'Local browser LLM is not supported in this browser.'); throw new Error(runtimeSupport.reason ?? 'Local browser LLM is not supported in this browser.');
@@ -102,6 +105,17 @@ export class LocalLLMModelManager {
} }
} }
private static logSoftRuntimeWarning(runtimeSupport: LocalLLMRuntimeSupport): void {
if (runtimeSupport.supported && runtimeSupport.reason) {
console.warn('[localLLM] Runtime warning:', runtimeSupport.reason, {
secureContext: runtimeSupport.secureContext,
webgpuExposed: runtimeSupport.webgpuExposed,
crossOriginIsolated: runtimeSupport.crossOriginIsolated,
sharedArrayBufferAvailable: runtimeSupport.sharedArrayBufferAvailable,
});
}
}
private static async cleanupLegacyEntries(): Promise<void> { private static async cleanupLegacyEntries(): Promise<void> {
await Promise.all( await Promise.all(
LOCAL_LLM_LEGACY_FILENAMES.map(async legacyFilename => { LOCAL_LLM_LEGACY_FILENAMES.map(async legacyFilename => {
+5 -31
View File
@@ -7,18 +7,15 @@ export interface ModelDownloadProgress {
interface OpfsModelCacheOptions { interface OpfsModelCacheOptions {
directoryName?: string; directoryName?: string;
sizeSuffix?: string; sizeSuffix?: string;
tempSuffix?: string;
} }
export class OpfsModelCache { export class OpfsModelCache {
private readonly directoryName: string; private readonly directoryName: string;
private readonly sizeSuffix: string; private readonly sizeSuffix: string;
private readonly tempSuffix: string;
constructor(options: OpfsModelCacheOptions = {}) { constructor(options: OpfsModelCacheOptions = {}) {
this.directoryName = options.directoryName ?? 'models'; this.directoryName = options.directoryName ?? 'models';
this.sizeSuffix = options.sizeSuffix ?? '.size'; this.sizeSuffix = options.sizeSuffix ?? '.size';
this.tempSuffix = options.tempSuffix ?? '.download';
} }
public async exists(filename: string): Promise<boolean> { public async exists(filename: string): Promise<boolean> {
@@ -62,8 +59,6 @@ export class OpfsModelCache {
const dir = await this.getDir(); const dir = await this.getDir();
await this.removeIfExists(dir, filename); await this.removeIfExists(dir, filename);
await this.removeIfExists(dir, this.getSizeFilename(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( public async download(
@@ -92,9 +87,8 @@ export class OpfsModelCache {
const dir = await this.getDir(); const dir = await this.getDir();
await this.delete(filename); await this.delete(filename);
const tempFilename = `${filename}${this.tempSuffix}`; const finalHandle = await dir.getFileHandle(filename, { create: true });
const tempHandle = await dir.getFileHandle(tempFilename, { create: true }); const finalWritable = await finalHandle.createWritable();
const tempWritable = await tempHandle.createWritable();
const reader = stream.getReader(); const reader = stream.getReader();
let receivedBytes = 0; let receivedBytes = 0;
@@ -103,7 +97,7 @@ export class OpfsModelCache {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
if (!value) continue; if (!value) continue;
await tempWritable.write(value); await finalWritable.write(value);
receivedBytes += value.byteLength; receivedBytes += value.byteLength;
onProgress?.({ onProgress?.({
receivedBytes, receivedBytes,
@@ -111,32 +105,13 @@ export class OpfsModelCache {
percent: totalBytes ? (receivedBytes / totalBytes) * 100 : 0, percent: totalBytes ? (receivedBytes / totalBytes) * 100 : 0,
}); });
} }
await tempWritable.close(); await finalWritable.close();
const sizeValue = totalBytes ?? receivedBytes; const sizeValue = totalBytes ?? receivedBytes;
if (!Number.isFinite(sizeValue) || sizeValue <= 0) { if (!Number.isFinite(sizeValue) || sizeValue <= 0) {
throw new Error('Model download did not provide a valid size.'); 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();
const tempBuffer = await tempFile.arrayBuffer();
console.log('[opfsModelCache] Temp file ready for finalize copy.', {
filename,
tempFilename,
tempSize: tempFile.size,
expectedSize: sizeValue,
});
await finalWritable.write(tempBuffer);
await finalWritable.close();
} catch (error) {
await finalWritable.abort();
throw error;
}
const sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename), { create: true }); const sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename), { create: true });
const sizeWritable = await sizeHandle.createWritable(); const sizeWritable = await sizeHandle.createWritable();
try { try {
@@ -158,14 +133,13 @@ export class OpfsModelCache {
}); });
} catch (error) { } catch (error) {
try { try {
await tempWritable.abort(); await finalWritable.abort();
} catch { } catch {
// Ignore abort cleanup errors. // Ignore abort cleanup errors.
} }
await this.delete(filename); await this.delete(filename);
throw error; throw error;
} finally { } finally {
await this.removeIfExists(dir, tempFilename);
reader.releaseLock(); reader.releaseLock();
} }
} }