fix: allow local Gemma runtime on GitHub Pages and avoid OPFS cache finalize read errors

This commit is contained in:
Xiaohan-Tian
2026-05-15 13:04:36 -07:00
parent b2bbc08761
commit e243f9e2ca
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 hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage;
return (
<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-card">
<h4 className="chatbox-local-mode-title">{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
{hasLocalRuntimeWarning && (
<div className="chatbox-local-runtime-warning">
{localRuntimeMessage}
</div>
)}
{hasLocalRuntimeHardFailure && (
<div className="chatbox-local-runtime-error">
{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 = {
supported: true,
webgpuExposed: true,
@@ -131,7 +131,8 @@ describe('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();
});
});
@@ -234,7 +234,6 @@ const GeneralSettings: React.FC = () => {
const localRuntimeMessage = localModelState.runtimeSupport.reason;
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
const hasLocalRuntimeWarning = localModelState.runtimeSupport.supported && !!localRuntimeMessage;
// NOTE: Gemini and Claude are not supported yet due to CORS issues.
return (
@@ -286,12 +285,6 @@ const GeneralSettings: React.FC = () => {
<div className="settings-group">
<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 && (
<div className="settings-help" style={{ fontSize: '12px', color: '#d45a5a', marginTop: '4px', marginBottom: '8px' }}>
{localRuntimeMessage}
+15 -1
View File
@@ -49,9 +49,11 @@ export class LocalLLMModelManager {
}
public static async refresh(): Promise<void> {
const runtimeSupport = detectLocalLLMRuntimeSupport();
this.logSoftRuntimeWarning(runtimeSupport);
this.setState({
isChecking: true,
runtimeSupport: detectLocalLLMRuntimeSupport(),
runtimeSupport,
});
try {
await this.cleanupLegacyEntries();
@@ -66,6 +68,7 @@ export class LocalLLMModelManager {
public static async ensureRuntimeSupported(): Promise<void> {
const runtimeSupport = detectLocalLLMRuntimeSupport();
this.logSoftRuntimeWarning(runtimeSupport);
this.setState({ runtimeSupport });
if (!runtimeSupport.supported) {
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> {
await Promise.all(
LOCAL_LLM_LEGACY_FILENAMES.map(async legacyFilename => {
+5 -31
View File
@@ -7,18 +7,15 @@ export interface ModelDownloadProgress {
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> {
@@ -62,8 +59,6 @@ export class OpfsModelCache {
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(
@@ -92,9 +87,8 @@ export class OpfsModelCache {
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 finalHandle = await dir.getFileHandle(filename, { create: true });
const finalWritable = await finalHandle.createWritable();
const reader = stream.getReader();
let receivedBytes = 0;
@@ -103,7 +97,7 @@ export class OpfsModelCache {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
await tempWritable.write(value);
await finalWritable.write(value);
receivedBytes += value.byteLength;
onProgress?.({
receivedBytes,
@@ -111,32 +105,13 @@ export class OpfsModelCache {
percent: totalBytes ? (receivedBytes / totalBytes) * 100 : 0,
});
}
await tempWritable.close();
await finalWritable.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();
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 sizeWritable = await sizeHandle.createWritable();
try {
@@ -158,14 +133,13 @@ export class OpfsModelCache {
});
} catch (error) {
try {
await tempWritable.abort();
await finalWritable.abort();
} catch {
// Ignore abort cleanup errors.
}
await this.delete(filename);
throw error;
} finally {
await this.removeIfExists(dir, tempFilename);
reader.releaseLock();
}
}