Finish the mobile performance pass
- Cache the model catalog once per user and each conversation's setting separately; clear both on logout and after model saves - Compress hashed assets asynchronously with a byte budget and set Vary - Memoize message parsing and throttle selection reads - Debounce draft writes to localStorage - Keep the composer editor on the page and focused, as before - Add a bundle budget check and run it in CI - Show a loading block while sessions load and a workspace avatar in the mobile conversation header
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type {
|
||||
BotHarnessSettingsPayload,
|
||||
HarnessCatalogPayload,
|
||||
} from "@/src/types";
|
||||
import {
|
||||
clearHarnessCache,
|
||||
readHarnessCatalogCache,
|
||||
readHarnessSettingsCache,
|
||||
writeHarnessCatalogCache,
|
||||
writeHarnessSettingsCache,
|
||||
} from "./harness-cache";
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return [...this.store.keys()][index] ?? null;
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const catalog: HarnessCatalogPayload = {
|
||||
harnesses: [
|
||||
{
|
||||
harness: "text",
|
||||
title: "Text",
|
||||
description: "Chats.",
|
||||
required: true,
|
||||
automatic: true,
|
||||
providers: [{ id: 1, name: "OpenRouter" }],
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settings: BotHarnessSettingsPayload = {
|
||||
harnesses: [
|
||||
{
|
||||
harness: "text",
|
||||
fallback: false,
|
||||
enabled: true,
|
||||
options: {},
|
||||
candidate_count: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let storage: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: storage as unknown as Storage,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("harness cache", () => {
|
||||
test("round-trips the catalog per user", () => {
|
||||
writeHarnessCatalogCache({ user_id: 1, data: catalog });
|
||||
|
||||
expect(readHarnessCatalogCache({ user_id: 1 })?.data).toEqual(catalog);
|
||||
expect(readHarnessCatalogCache({ user_id: 2 })).toBeNull();
|
||||
});
|
||||
|
||||
test("round-trips a conversation's settings per user, bot and workspace", () => {
|
||||
writeHarnessSettingsCache({
|
||||
user_id: 1,
|
||||
bot_id: 7,
|
||||
workspace_id: 3,
|
||||
data: settings,
|
||||
});
|
||||
|
||||
expect(readHarnessSettingsCache({ user_id: 1, bot_id: 7, workspace_id: 3 })?.data).toEqual(
|
||||
settings,
|
||||
);
|
||||
expect(readHarnessSettingsCache({ user_id: 2, bot_id: 7, workspace_id: 3 })).toBeNull();
|
||||
expect(readHarnessSettingsCache({ user_id: 1, bot_id: 8, workspace_id: 3 })).toBeNull();
|
||||
});
|
||||
|
||||
test("ignores another schema version", () => {
|
||||
storage.setItem(
|
||||
`${AppData.HarnessCacheStorageKey}:catalog:1`,
|
||||
JSON.stringify({ v: 0, at: Date.now(), data: catalog }),
|
||||
);
|
||||
|
||||
expect(readHarnessCatalogCache({ user_id: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
test("clears the new and the older per-conversation keys only", () => {
|
||||
writeHarnessCatalogCache({ user_id: 1, data: catalog });
|
||||
writeHarnessSettingsCache({ user_id: 1, bot_id: 7, data: settings });
|
||||
storage.setItem(`${AppData.LegacyHarnessOverviewStorageKey}:7:0`, "{}");
|
||||
storage.setItem(`${AppData.ComposerDraftStorageKey}:7`, "draft");
|
||||
|
||||
clearHarnessCache();
|
||||
|
||||
expect(readHarnessCatalogCache({ user_id: 1 })).toBeNull();
|
||||
expect(readHarnessSettingsCache({ user_id: 1, bot_id: 7 })).toBeNull();
|
||||
expect(storage.getItem(`${AppData.LegacyHarnessOverviewStorageKey}:7:0`)).toBeNull();
|
||||
expect(storage.getItem(`${AppData.ComposerDraftStorageKey}:7`)).toBe("draft");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type {
|
||||
BotHarnessSettingsPayload,
|
||||
HarnessCatalogPayload,
|
||||
} from "@/src/types";
|
||||
|
||||
/**
|
||||
* Bumped when a cached model payload changes shape, so an older cache is
|
||||
* fetched again instead of read.
|
||||
*/
|
||||
const CacheVersion = 1;
|
||||
|
||||
type CacheEnvelope<T> = {
|
||||
v: number;
|
||||
/**
|
||||
* Unix ms it was fetched.
|
||||
*/
|
||||
at: number;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type UserKey = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type SettingsKey = UserKey & {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
function catalogKey({ user_id }: UserKey) {
|
||||
return `${AppData.HarnessCacheStorageKey}:catalog:${user_id}`;
|
||||
}
|
||||
|
||||
function settingsKey({ user_id, bot_id, workspace_id }: SettingsKey) {
|
||||
return `${AppData.HarnessCacheStorageKey}:settings:${user_id}:${bot_id}:${workspace_id || 0}`;
|
||||
}
|
||||
|
||||
function read<T>(key: string): CacheEnvelope<T> | null {
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(key) || "null");
|
||||
|
||||
return cached?.v == CacheVersion &&
|
||||
typeof cached.at == "number" &&
|
||||
Array.isArray(cached.data?.harnesses)
|
||||
? cached
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write<T>(key: string, data: T) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ v: CacheVersion, at: Date.now(), data } satisfies CacheEnvelope<T>),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The install-wide model catalog this user last fetched, from
|
||||
* `localStorage`; null when there is none or it can't be read.
|
||||
*/
|
||||
export function readHarnessCatalogCache(key: UserKey) {
|
||||
return read<HarnessCatalogPayload>(catalogKey(key));
|
||||
}
|
||||
|
||||
export function writeHarnessCatalogCache({
|
||||
user_id,
|
||||
data,
|
||||
}: UserKey & { data: HarnessCatalogPayload }) {
|
||||
write(catalogKey({ user_id }), data);
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation's model setting this user last fetched, from
|
||||
* `localStorage`; null when there is none or it can't be read.
|
||||
*/
|
||||
export function readHarnessSettingsCache(key: SettingsKey) {
|
||||
return read<BotHarnessSettingsPayload>(settingsKey(key));
|
||||
}
|
||||
|
||||
export function writeHarnessSettingsCache({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
data,
|
||||
}: SettingsKey & { data: BotHarnessSettingsPayload }) {
|
||||
write(settingsKey({ user_id, bot_id, workspace_id }), data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every model cache, including the per-conversation keys an older
|
||||
* version wrote. Called on logout and after model settings change
|
||||
* elsewhere, so the modal can't show a stale pick.
|
||||
*/
|
||||
export function clearHarnessCache() {
|
||||
const prefixes = [
|
||||
AppData.HarnessCacheStorageKey,
|
||||
AppData.LegacyHarnessOverviewStorageKey,
|
||||
];
|
||||
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
|
||||
for (let index = 0; index < localStorage.length; index++) {
|
||||
const key = localStorage.key(index);
|
||||
|
||||
if (key) keys.push(key);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (prefixes.some((prefix) => key.startsWith(prefix))) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
Reference in New Issue
Block a user