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:
@@ -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}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user