mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-19 01:47:06 +00:00
perf: reduce startup work and fix grade analytics bar chart animation
Batch settings storage writes, tier plugin startup, lazy-load heavy UI chunks, and optimize global search indexing. Stop tweening bar height in grade analytics to prevent invalid negative SVG rect values. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -131,16 +131,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
|
||||
if (requestId !== loadRequestId) return;
|
||||
|
||||
renderSkeletonLoader(container);
|
||||
void renderSkeletonLoader(container);
|
||||
|
||||
try {
|
||||
const data = await getAssessmentsData();
|
||||
if (requestId !== loadRequestId) return;
|
||||
renderGrid(container, data);
|
||||
void renderGrid(container, data);
|
||||
} catch (err) {
|
||||
if (requestId !== loadRequestId) return;
|
||||
console.error("Failed to load assessments:", err);
|
||||
renderErrorState(
|
||||
void renderErrorState(
|
||||
container,
|
||||
err instanceof Error ? err.message : "Unknown error",
|
||||
);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import AssessmentsOverview from "./AssessmentsOverview.svelte";
|
||||
import SkeletonLoader from "./SkeletonLoader.svelte";
|
||||
import ErrorState from "./ErrorState.svelte";
|
||||
import { unmount } from "svelte";
|
||||
|
||||
let currentApp: any = null;
|
||||
@@ -119,26 +115,42 @@ function prepareContainer(container: HTMLElement) {
|
||||
watchOverviewTheme(container);
|
||||
}
|
||||
|
||||
export function renderGrid(container: HTMLElement, data: any) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(AssessmentsOverview, container, { data });
|
||||
async function mountOverviewComponent(
|
||||
container: HTMLElement,
|
||||
loader: () => Promise<{ default: any }>,
|
||||
props: Record<string, unknown> = {},
|
||||
) {
|
||||
const [{ default: renderSvelte }, { default: Component }] = await Promise.all([
|
||||
import("@/interface/main"),
|
||||
loader(),
|
||||
]);
|
||||
currentApp = renderSvelte(Component, container, props);
|
||||
}
|
||||
|
||||
export function renderSkeletonLoader(container: HTMLElement) {
|
||||
export async function renderGrid(container: HTMLElement, data: any) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(SkeletonLoader, container);
|
||||
await mountOverviewComponent(container, () => import("./AssessmentsOverview.svelte"), {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderLoadingState(container: HTMLElement) {
|
||||
renderSkeletonLoader(container);
|
||||
}
|
||||
|
||||
export function renderErrorState(container: HTMLElement, error: string) {
|
||||
export async function renderSkeletonLoader(container: HTMLElement) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(ErrorState, container, { error });
|
||||
await mountOverviewComponent(container, () => import("./SkeletonLoader.svelte"));
|
||||
}
|
||||
|
||||
export async function renderLoadingState(container: HTMLElement) {
|
||||
await renderSkeletonLoader(container);
|
||||
}
|
||||
|
||||
export async function renderErrorState(container: HTMLElement, error: string) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
await mountOverviewComponent(container, () => import("./ErrorState.svelte"), {
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
export function teardownOverviewUi() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { circOut, quintOut } from 'svelte/easing';
|
||||
import { type StaticCommandItem } from '../core/commands';
|
||||
import type { CombinedResult } from '../core/types';
|
||||
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
|
||||
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils';
|
||||
import Fuse from 'fuse.js';
|
||||
import Calculator from './Calculator.svelte';
|
||||
import { actionMap } from '../indexing/actions';
|
||||
@@ -129,7 +129,31 @@
|
||||
|
||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||
|
||||
const itemsUpdatedHandler = () => {
|
||||
const itemsUpdatedHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||
|
||||
if (
|
||||
detail?.vectorUpdate &&
|
||||
!detail.changedItems?.length &&
|
||||
!detail.removedIds?.length
|
||||
) {
|
||||
performSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail?.incremental && !detail.fullRebuild) {
|
||||
const updatedFuse = applyDynamicIndexDelta(
|
||||
dynamicContentFuse,
|
||||
dynamicIdToItemMap,
|
||||
detail,
|
||||
);
|
||||
if (updatedFuse) {
|
||||
dynamicContentFuse = updatedFuse;
|
||||
performSearch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setupSearchIndexes();
|
||||
performSearch();
|
||||
};
|
||||
|
||||
@@ -286,10 +286,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
const title = document.querySelector("#title");
|
||||
|
||||
if (title) {
|
||||
mountSearchBar(title, api, appRef);
|
||||
void mountSearchBar(title, api, appRef);
|
||||
} else {
|
||||
const titleElement = await waitForElm("#title", true, 100, 60);
|
||||
mountSearchBar(titleElement, api, appRef);
|
||||
void mountSearchBar(titleElement, api, appRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import SearchBar from "../components/SearchBar.svelte";
|
||||
import { unmount } from "svelte";
|
||||
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export function mountSearchBar(
|
||||
export async function mountSearchBar(
|
||||
titleElement: Element,
|
||||
api: any,
|
||||
appRef: {
|
||||
@@ -305,6 +304,7 @@ export function mountSearchBar(
|
||||
});
|
||||
|
||||
try {
|
||||
const { default: renderSvelte } = await import("@/interface/main");
|
||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||
showRecentFirst: api.settings.showRecentFirst,
|
||||
|
||||
@@ -184,6 +184,56 @@ export async function put(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply puts and deletes in a single readwrite transaction.
|
||||
*/
|
||||
export async function applyStoreDiff(
|
||||
store: string,
|
||||
puts: Array<{ key: string; value: any }>,
|
||||
removeKeys: string[],
|
||||
): Promise<void> {
|
||||
if (puts.length === 0 && removeKeys.length === 0) return;
|
||||
|
||||
try {
|
||||
const db = await openDB();
|
||||
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
await upgradeDB(store);
|
||||
const upgradedDb = await openDB();
|
||||
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
await runStoreDiffTransaction(db, store, puts, removeKeys);
|
||||
} catch (error) {
|
||||
console.error(`Error in applyStoreDiff for store ${store}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runStoreDiffTransaction(
|
||||
db: IDBDatabase,
|
||||
store: string,
|
||||
puts: Array<{ key: string; value: any }>,
|
||||
removeKeys: string[],
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, "readwrite");
|
||||
const objectStore = tx.objectStore(store);
|
||||
|
||||
for (const key of removeKeys) {
|
||||
objectStore.delete(key);
|
||||
}
|
||||
for (const { key, value } of puts) {
|
||||
objectStore.put(value, key);
|
||||
}
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(store: string, key: string): Promise<void> {
|
||||
try {
|
||||
const s = await getStore(store, "readwrite");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
|
||||
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
|
||||
import { jobs } from "./jobs";
|
||||
import { renderComponentMap } from "./renderComponents";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
import type { IndexItem, Job, JobContext } from "./types";
|
||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
@@ -89,12 +89,64 @@ function shouldRun(job: Job, lastRun?: number): boolean {
|
||||
}
|
||||
|
||||
function getLastRunMeta(jobId: string): Promise<number | undefined> {
|
||||
return getAll(META_STORE).then((metaItems) => {
|
||||
const match = metaItems.find((m: any) => m.jobId === jobId);
|
||||
return match?.lastRun;
|
||||
return get(META_STORE, jobId).then((rec) => rec?.lastRun);
|
||||
}
|
||||
|
||||
function indexItemStorageKey(item: IndexItem): string {
|
||||
return JSON.stringify({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
category: item.category,
|
||||
content: item.content,
|
||||
dateAdded: item.dateAdded,
|
||||
metadata: item.metadata,
|
||||
actionId: item.actionId,
|
||||
renderComponentId: item.renderComponentId,
|
||||
});
|
||||
}
|
||||
|
||||
function indexItemsEqual(a: IndexItem, b: IndexItem): boolean {
|
||||
return indexItemStorageKey(a) === indexItemStorageKey(b);
|
||||
}
|
||||
|
||||
async function diffAndStoreItems(
|
||||
targetStore: string,
|
||||
items: IndexItem[],
|
||||
): Promise<void> {
|
||||
const validItems = items.filter((i) => i && i.id);
|
||||
if (validItems.length !== items.length) {
|
||||
console.warn(
|
||||
`[Indexer] Filtered out ${items.length - validItems.length} invalid items before storing in '${targetStore}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = (await getAll(targetStore)) as IndexItem[];
|
||||
const existingMap = new Map(
|
||||
existing.filter((i) => i?.id).map((i) => [i.id, i]),
|
||||
);
|
||||
const newMap = new Map(validItems.map((i) => [i.id, i]));
|
||||
|
||||
const puts: Array<{ key: string; value: IndexItem }> = [];
|
||||
const removeKeys: string[] = [];
|
||||
|
||||
for (const [id, item] of newMap) {
|
||||
const prev = existingMap.get(id);
|
||||
if (!prev || !indexItemsEqual(prev, item)) {
|
||||
puts.push({ key: id, value: item });
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of existingMap.keys()) {
|
||||
if (!newMap.has(id)) {
|
||||
removeKeys.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (puts.length > 0 || removeKeys.length > 0) {
|
||||
await applyStoreDiff(targetStore, puts, removeKeys);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||
}
|
||||
@@ -255,14 +307,7 @@ export async function runIndexing(): Promise<void> {
|
||||
await getAll(storeId ?? jobId);
|
||||
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
|
||||
const targetStore = storeId ?? jobId;
|
||||
await clear(targetStore);
|
||||
const validItems = items.filter((i) => i && i.id);
|
||||
if (validItems.length !== items.length) {
|
||||
console.warn(
|
||||
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
|
||||
);
|
||||
}
|
||||
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
|
||||
await diffAndStoreItems(targetStore, items);
|
||||
};
|
||||
const addItem = async (item: IndexItem, storeId?: string) => {
|
||||
const targetStore = storeId ?? jobId;
|
||||
@@ -447,35 +492,13 @@ export async function runIndexing(): Promise<void> {
|
||||
}
|
||||
|
||||
allItemsInPrimaryStores = await loadAllStoredItems();
|
||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
|
||||
try {
|
||||
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
|
||||
let renderComponent = item.renderComponent;
|
||||
if (jobDef) {
|
||||
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||
} else if (renderComponentMap[item.renderComponentId]) {
|
||||
renderComponent = renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
||||
// Use JSON serialization to ensure all nested properties are accessible
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
// Fallback to shallow copy if deep clone fails
|
||||
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
|
||||
return item;
|
||||
}
|
||||
});
|
||||
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
|
||||
loadDynamicItems(itemsWithComponents);
|
||||
window.dispatchEvent(new Event("dynamic-items-updated"));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: { fullRebuild: true },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IndexItem } from "./types";
|
||||
import { put, getAll } from "./db";
|
||||
import { getAll, put } from "./db";
|
||||
import {
|
||||
buildIndexItem,
|
||||
extractTextFromValue,
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
pickTitle,
|
||||
} from "./extract";
|
||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||
import { loadAllStoredItems } from "./indexer";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
import { renderComponentMap } from "./renderComponents";
|
||||
import { jobs } from "./jobs";
|
||||
import { mergeDynamicItems } from "../utils/dynamicItems";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
|
||||
/**
|
||||
* Passive network observer.
|
||||
@@ -41,6 +39,8 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
|
||||
let installed = false;
|
||||
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingDirty = false;
|
||||
/** Items persisted since the last flush — only these are pushed to the search layer. */
|
||||
const pendingChangedItems = new Map<string, IndexItem>();
|
||||
|
||||
export function isPassiveObserverInstalled(): boolean {
|
||||
return installed;
|
||||
@@ -386,6 +386,7 @@ async function persistItems(items: IndexItem[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
try {
|
||||
await put(STORE_ID, item, item.id);
|
||||
pendingChangedItems.set(item.id, item);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[Passive Observer] Failed to persist item ${item.id}:`,
|
||||
@@ -409,38 +410,20 @@ function scheduleFlush() {
|
||||
}
|
||||
|
||||
async function flushDynamicItems(): Promise<void> {
|
||||
if (pendingChangedItems.size === 0) return;
|
||||
|
||||
const rawChanged = Array.from(pendingChangedItems.values());
|
||||
pendingChangedItems.clear();
|
||||
|
||||
try {
|
||||
const all = await loadAllStoredItems();
|
||||
const decorated = all.map((item) => {
|
||||
try {
|
||||
const jobDef =
|
||||
jobs[item.category] ||
|
||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||
jobs[item.renderComponentId];
|
||||
let renderComponent = item.renderComponent;
|
||||
if (jobDef) {
|
||||
renderComponent =
|
||||
renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||
} else if (renderComponentMap[item.renderComponentId]) {
|
||||
renderComponent = renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch {
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
loadDynamicItems(decorated);
|
||||
const decorated = decorateIndexItems(rawChanged);
|
||||
mergeDynamicItems(decorated);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: {
|
||||
incremental: true,
|
||||
jobId: STORE_ID,
|
||||
changedItems: decorated,
|
||||
streaming: false,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,8 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
||||
import ForumItem from "../components/items/ForumItem.svelte";
|
||||
import SubjectItem from "../components/items/SubjectItem.svelte";
|
||||
import GenericItem from "../components/items/GenericItem.svelte";
|
||||
import type { IndexItem } from "./types";
|
||||
import { jobs } from "./jobs";
|
||||
|
||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||
@@ -22,3 +24,37 @@ export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||
goal: GenericItem as unknown as typeof SvelteComponent,
|
||||
passive: GenericItem as unknown as typeof SvelteComponent,
|
||||
};
|
||||
|
||||
function resolveRenderComponent(item: IndexItem): typeof SvelteComponent | undefined {
|
||||
const jobDef =
|
||||
jobs[item.category] ||
|
||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||
jobs[item.renderComponentId];
|
||||
if (jobDef) {
|
||||
return renderComponentMap[jobDef.renderComponentId] || item.renderComponent;
|
||||
}
|
||||
if (renderComponentMap[item.renderComponentId]) {
|
||||
return renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
return item.renderComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach render components and deep-clone items for search UI (Firefox XrayWrapper).
|
||||
*/
|
||||
export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
|
||||
return items.map((item) => {
|
||||
try {
|
||||
const renderComponent = resolveRenderComponent(item);
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item)) as IndexItem;
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch {
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function hybridSearch(
|
||||
bm25Results: CombinedResult[],
|
||||
query: string,
|
||||
options: HybridSearchOptions = {},
|
||||
precomputedVectorResults?: VectorSearchResult[],
|
||||
): Promise<CombinedResult[]> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
@@ -146,9 +147,10 @@ export async function hybridSearch(
|
||||
|
||||
if (trimmedQuery.length > 2) {
|
||||
try {
|
||||
// Get more vector results than BM25 results to ensure coverage
|
||||
// This allows us to find semantic matches that BM25 might have missed
|
||||
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||
const vectorTopK = opts.bm25TopK * 2;
|
||||
const vectorSearchResults =
|
||||
precomputedVectorResults ??
|
||||
(await searchVectors(trimmedQuery, vectorTopK));
|
||||
|
||||
// Create a map of item ID to vector similarity
|
||||
const vectorMap = new Map<string, number>();
|
||||
@@ -249,14 +251,26 @@ export async function hybridSearchWithExpansion(
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const liveIndexIds = new Set(allItems.map((item) => item.id));
|
||||
|
||||
// First, rerank BM25 results
|
||||
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
|
||||
|
||||
// If query is too short, skip vector expansion
|
||||
if (trimmedQuery.length <= 2) {
|
||||
return rerankedBm25;
|
||||
return hybridSearch(bm25Results, query, options);
|
||||
}
|
||||
|
||||
let vectorResults: VectorSearchResult[] = [];
|
||||
try {
|
||||
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||
} catch (e) {
|
||||
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||
return hybridSearch(bm25Results, query, options);
|
||||
}
|
||||
|
||||
// Rerank BM25 results using the single vector pass above
|
||||
const rerankedBm25 = await hybridSearch(
|
||||
bm25Results,
|
||||
query,
|
||||
options,
|
||||
vectorResults,
|
||||
);
|
||||
|
||||
// For short / single-token queries vector expansion brings in too much
|
||||
// noise (and is the main reason results "flicker" between adjacent
|
||||
// keystrokes). Keep semantic recall for longer queries.
|
||||
@@ -264,15 +278,6 @@ export async function hybridSearchWithExpansion(
|
||||
return rerankedBm25.slice(0, opts.finalLimit);
|
||||
}
|
||||
|
||||
// Get vector search results
|
||||
let vectorResults: VectorSearchResult[] = [];
|
||||
try {
|
||||
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
|
||||
} catch (e) {
|
||||
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||
return rerankedBm25;
|
||||
}
|
||||
|
||||
// Find vector results that weren't in BM25 results
|
||||
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
|
||||
const vectorOnlyResults: CombinedResult[] = [];
|
||||
|
||||
@@ -101,6 +101,94 @@ if (typeof window !== 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
/** Rebuild Fuse when incremental delta exceeds this count. */
|
||||
export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
|
||||
|
||||
export const DYNAMIC_FUSE_OPTIONS = {
|
||||
keys: [
|
||||
{ name: "text", weight: 3 },
|
||||
{ name: "content", weight: 1 },
|
||||
{ name: "category", weight: 0.4 },
|
||||
{ name: "metadata.subjectName", weight: 1.6 },
|
||||
{ name: "metadata.subjectCode", weight: 1.6 },
|
||||
{ name: "metadata.subject", weight: 1.4 },
|
||||
{ name: "metadata.courseCode", weight: 1.2 },
|
||||
{ name: "metadata.filename", weight: 1.2 },
|
||||
{ name: "metadata.author", weight: 0.8 },
|
||||
{ name: "metadata.authorName", weight: 0.8 },
|
||||
{ name: "metadata.label", weight: 0.6 },
|
||||
{ name: "metadata.categoryName", weight: 0.6 },
|
||||
{ name: "metadata.entityType", weight: 0.4 },
|
||||
],
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
threshold: 0.5,
|
||||
minMatchCharLength: 2,
|
||||
distance: 100,
|
||||
useExtendedSearch: true,
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true,
|
||||
shouldSort: true,
|
||||
} as const;
|
||||
|
||||
export interface DynamicItemsUpdatedDetail {
|
||||
incremental?: boolean;
|
||||
fullRebuild?: boolean;
|
||||
jobId?: string;
|
||||
changedItems?: IndexItem[];
|
||||
removedIds?: string[];
|
||||
vectorUpdate?: boolean;
|
||||
streaming?: boolean;
|
||||
newItemCount?: number;
|
||||
}
|
||||
|
||||
export function createDynamicContentFuse(
|
||||
items: IndexItem[],
|
||||
): Fuse<IndexItem> {
|
||||
return new Fuse(
|
||||
dedupeIndexItemsForSearch(items),
|
||||
DYNAMIC_FUSE_OPTIONS,
|
||||
) as Fuse<IndexItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an incremental dynamic-item delta to an existing Fuse index.
|
||||
* Returns null when a full rebuild is recommended.
|
||||
*/
|
||||
export function applyDynamicIndexDelta(
|
||||
fuse: Fuse<IndexItem> | undefined,
|
||||
idToItemMap: Map<string, IndexItem>,
|
||||
detail: DynamicItemsUpdatedDetail,
|
||||
): Fuse<IndexItem> | null {
|
||||
const changedItems = detail.changedItems ?? [];
|
||||
const removedIds = detail.removedIds ?? [];
|
||||
const deltaSize = changedItems.length + removedIds.length;
|
||||
|
||||
if (
|
||||
detail.fullRebuild ||
|
||||
!fuse ||
|
||||
idToItemMap.size === 0 ||
|
||||
deltaSize === 0 ||
|
||||
deltaSize > INCREMENTAL_FUSE_REBUILD_THRESHOLD
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const id of removedIds) {
|
||||
fuse.remove((item) => item.id === id);
|
||||
idToItemMap.delete(id);
|
||||
}
|
||||
|
||||
for (const item of changedItems) {
|
||||
fuse.remove((existing) => existing.id === item.id);
|
||||
fuse.add(item);
|
||||
idToItemMap.set(item.id, item);
|
||||
}
|
||||
|
||||
clearSearchCache();
|
||||
return fuse;
|
||||
}
|
||||
|
||||
export function createSearchIndexes() {
|
||||
clearSearchCache();
|
||||
const commands = getStaticCommands();
|
||||
@@ -118,49 +206,9 @@ export function createSearchIndexes() {
|
||||
findAllMatches: false, // Performance optimization
|
||||
};
|
||||
|
||||
// Optimized dynamic content search options.
|
||||
// The expanded corpus mixes structured entities (assessments, subjects)
|
||||
// with free-form text (course content, notices, folio bodies, passive
|
||||
// captures) so we list a broad set of metadata keys while keeping titles
|
||||
// dominant in the ranking.
|
||||
// NOTE: metadata.route is intentionally excluded. Raw API paths like
|
||||
// `/seqta/student/load/message/people` should never influence ranking — they
|
||||
// historically caused passive-capture support records to bubble up above
|
||||
// real assessments when the user typed substrings that happened to appear in
|
||||
// the path.
|
||||
const dynamicOptions = {
|
||||
keys: [
|
||||
{ name: "text", weight: 3 }, // Title is king
|
||||
{ name: "content", weight: 1 },
|
||||
{ name: "category", weight: 0.4 },
|
||||
{ name: "metadata.subjectName", weight: 1.6 },
|
||||
{ name: "metadata.subjectCode", weight: 1.6 },
|
||||
{ name: "metadata.subject", weight: 1.4 },
|
||||
{ name: "metadata.courseCode", weight: 1.2 },
|
||||
{ name: "metadata.filename", weight: 1.2 },
|
||||
{ name: "metadata.author", weight: 0.8 },
|
||||
{ name: "metadata.authorName", weight: 0.8 },
|
||||
{ name: "metadata.label", weight: 0.6 },
|
||||
{ name: "metadata.categoryName", weight: 0.6 },
|
||||
{ name: "metadata.entityType", weight: 0.4 },
|
||||
],
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
threshold: 0.5,
|
||||
minMatchCharLength: 2,
|
||||
distance: 100,
|
||||
useExtendedSearch: true,
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true,
|
||||
shouldSort: true,
|
||||
};
|
||||
|
||||
return {
|
||||
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||
dynamicContentFuse: new Fuse(
|
||||
dynamicItems,
|
||||
dynamicOptions,
|
||||
) as Fuse<IndexItem>,
|
||||
dynamicContentFuse: createDynamicContentFuse(dynamicItems),
|
||||
commands,
|
||||
dynamicItems,
|
||||
};
|
||||
|
||||
@@ -16,12 +16,29 @@ export interface DynamicContentItem {
|
||||
let dynamicItems: IndexItem[] = [];
|
||||
|
||||
/**
|
||||
* Loads a new set of dynamic items.
|
||||
* Loads a new set of dynamic items (full replace).
|
||||
*/
|
||||
export function loadDynamicItems(items: IndexItem[]) {
|
||||
dynamicItems = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge changed items and remove deleted ids without reloading the full corpus.
|
||||
*/
|
||||
export function mergeDynamicItems(
|
||||
changedItems: IndexItem[],
|
||||
removedIds: string[] = [],
|
||||
): void {
|
||||
if (changedItems.length === 0 && removedIds.length === 0) return;
|
||||
|
||||
const removeSet = new Set(removedIds);
|
||||
const changeMap = new Map(changedItems.map((item) => [item.id, item]));
|
||||
const kept = dynamicItems.filter(
|
||||
(item) => !removeSet.has(item.id) && !changeMap.has(item.id),
|
||||
);
|
||||
dynamicItems = [...kept, ...changedItems];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all currently loaded dynamic items.
|
||||
*/
|
||||
|
||||
@@ -310,8 +310,6 @@
|
||||
|
||||
y: { type: "tween", duration: 600, easing: cubicInOut },
|
||||
|
||||
height: { type: "tween", duration: 600, easing: cubicInOut },
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
import { unmount } from "svelte";
|
||||
import themeCreator from "@/interface/pages/themeCreator.svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
let themeCreatorSvelteApp: any = null;
|
||||
@@ -11,10 +9,15 @@ let themeCreatorSvelteApp: any = null;
|
||||
* @param themeID - The ID of the theme to load in the Theme Creator
|
||||
* @returns void
|
||||
*/
|
||||
export function OpenThemeCreator(themeID: string = "") {
|
||||
export async function OpenThemeCreator(themeID: string = "") {
|
||||
CloseThemeCreator();
|
||||
|
||||
// Only store original color if we're not editing an existing theme
|
||||
const [{ default: renderSvelte }, { default: themeCreator }] =
|
||||
await Promise.all([
|
||||
import("@/interface/main"),
|
||||
import("@/interface/pages/themeCreator.svelte"),
|
||||
]);
|
||||
|
||||
localStorage.setItem("themeCreatorOpen", "true");
|
||||
if (!themeID) {
|
||||
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
|
||||
@@ -34,7 +37,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
||||
const mainContent = document.querySelector("#container") as HTMLDivElement;
|
||||
if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
|
||||
|
||||
// close button
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.classList.add("themeCloseButton");
|
||||
closeButton.textContent = "×";
|
||||
@@ -92,7 +94,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
||||
* @returns void
|
||||
*/
|
||||
export function CloseThemeCreator() {
|
||||
// Remove the stored flag
|
||||
localStorage.removeItem("themeCreatorOpen");
|
||||
|
||||
const themeCreator = document.getElementById("themeCreator");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
||||
import ReactFiber from "@/seqta/utils/ReactFiber";
|
||||
import browser from "webextension-polyfill";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
|
||||
function createSEQTAAPI(): SEQTAAPI {
|
||||
return {
|
||||
@@ -149,29 +150,27 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
|
||||
settingsWithMeta.loaded = loaded;
|
||||
|
||||
// Listen for storage changes and update settingsWithMeta
|
||||
const handleStorageChange = (
|
||||
changes: { [key: string]: browser.Storage.StorageChange },
|
||||
area: string,
|
||||
) => {
|
||||
if (area !== "local" || !(storageKey in changes)) return;
|
||||
const handleSettingsChange = (newValue: unknown) => {
|
||||
if (!newValue || typeof newValue !== "object") return;
|
||||
|
||||
const newValue = changes[storageKey].newValue as
|
||||
| Partial<Record<keyof T, any>>
|
||||
| undefined;
|
||||
if (!newValue) return;
|
||||
|
||||
for (const key in newValue) {
|
||||
const newSettings = newValue as Partial<Record<keyof T, any>>;
|
||||
for (const key in newSettings) {
|
||||
const typedKey = key as keyof T;
|
||||
settingsWithMeta[typedKey] = newValue[typedKey];
|
||||
listeners.get(typedKey)?.forEach((cb) => cb(newValue[typedKey]));
|
||||
settingsWithMeta[typedKey] = newSettings[typedKey];
|
||||
listeners.get(typedKey)?.forEach((cb) => cb(newSettings[typedKey]));
|
||||
}
|
||||
};
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
settingsState.register(
|
||||
storageKey as keyof SettingsState,
|
||||
handleSettingsChange,
|
||||
);
|
||||
|
||||
const dispose = () => {
|
||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
||||
settingsState.unregister(
|
||||
storageKey as keyof SettingsState,
|
||||
handleSettingsChange,
|
||||
);
|
||||
};
|
||||
|
||||
const proxy = new Proxy(settingsWithMeta, {
|
||||
@@ -241,29 +240,22 @@ function createStorageAPI<T = any>(
|
||||
}
|
||||
})();
|
||||
|
||||
// Listen for storage changes
|
||||
const handleStorageChange = (
|
||||
changes: { [key: string]: any },
|
||||
area: string,
|
||||
newValue: unknown,
|
||||
_oldValue: unknown,
|
||||
key: string,
|
||||
) => {
|
||||
if (area === "local") {
|
||||
Object.entries(changes).forEach(([key, change]) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
const shortKey = key.slice(prefix.length);
|
||||
cache[shortKey] = change.newValue;
|
||||
if (!key.startsWith(prefix)) return;
|
||||
|
||||
// Notify listeners
|
||||
listeners
|
||||
.get(shortKey)
|
||||
?.forEach((callback) => callback(change.newValue));
|
||||
}
|
||||
});
|
||||
}
|
||||
const shortKey = key.slice(prefix.length);
|
||||
cache[shortKey] = newValue;
|
||||
listeners.get(shortKey)?.forEach((callback) => callback(newValue));
|
||||
};
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
|
||||
settingsState.registerGlobal(handleStorageChange);
|
||||
|
||||
const dispose = () => {
|
||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
||||
settingsState.unregisterGlobal(handleStorageChange);
|
||||
};
|
||||
|
||||
// Create the proxy for direct property access
|
||||
|
||||
+44
-11
@@ -23,6 +23,23 @@ interface StorageChange<T = any> {
|
||||
newValue?: T;
|
||||
}
|
||||
|
||||
/** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */
|
||||
const PLUGIN_START_PHASES: readonly string[][] = [
|
||||
["themes", "animated-background"],
|
||||
[
|
||||
"timetable",
|
||||
"timetableEdit",
|
||||
"notificationCollector",
|
||||
"enhanced-navigation",
|
||||
"assessments-overview",
|
||||
"assessments-average",
|
||||
"messageFolders",
|
||||
"profile-picture",
|
||||
"background-music",
|
||||
],
|
||||
["global-search", "grade-analytics"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Singleton class responsible for the entire lifecycle of plugins.
|
||||
* This includes registration, starting, stopping, event dispatching,
|
||||
@@ -215,25 +232,41 @@ export class PluginManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to start all registered plugins.
|
||||
* Errors during the start of individual plugins are caught and logged,
|
||||
* allowing other plugins to attempt to start.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
||||
* It uses `Promise.allSettled` to wait for all start operations.
|
||||
*/
|
||||
public async startAllPlugins(): Promise<void> {
|
||||
const startPromises = Array.from(this.plugins.keys()).map((id) =>
|
||||
private async startPluginPhase(pluginIds: string[]): Promise<void> {
|
||||
const registeredIds = new Set(this.plugins.keys());
|
||||
const idsToStart = pluginIds.filter((id) => registeredIds.has(id));
|
||||
|
||||
const startPromises = idsToStart.map((id) =>
|
||||
this.startPlugin(id).catch((error) => {
|
||||
console.error(`Failed to start plugin "${id}":`, error);
|
||||
return Promise.reject(error); // Still reject to indicate failure for this specific plugin if needed by caller
|
||||
return Promise.reject(error);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.allSettled(startPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to start all registered plugins in phased order.
|
||||
* Errors during the start of individual plugins are caught and logged,
|
||||
* allowing other plugins to attempt to start.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
||||
*/
|
||||
public async startAllPlugins(): Promise<void> {
|
||||
for (const phase of PLUGIN_START_PHASES) {
|
||||
await this.startPluginPhase(phase);
|
||||
}
|
||||
|
||||
const phasedIds = new Set(PLUGIN_START_PHASES.flat());
|
||||
const remainingIds = Array.from(this.plugins.keys()).filter(
|
||||
(id) => !phasedIds.has(id),
|
||||
);
|
||||
if (remainingIds.length > 0) {
|
||||
await this.startPluginPhase(remainingIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a specific plugin by its ID.
|
||||
* This involves:
|
||||
|
||||
@@ -39,8 +39,6 @@ pluginManager.registerPlugin(enhancedNavigationPlugin);
|
||||
pluginManager.registerPlugin(globalSearchPluginLazy);
|
||||
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
|
||||
|
||||
export { init as Monofile } from "./monofile";
|
||||
|
||||
export async function initializePlugins(): Promise<void> {
|
||||
await pluginManager.startAllPlugins();
|
||||
}
|
||||
|
||||
+34
-19
@@ -26,7 +26,6 @@ import {
|
||||
updateEngageHomeMenuActive,
|
||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
|
||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||
|
||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||
@@ -337,7 +336,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||
break;
|
||||
case "analytics":
|
||||
console.info("[BetterSEQTA+] Started Init (Analytics)");
|
||||
if (settingsState.onoff) void loadAnalyticsPage();
|
||||
if (settingsState.onoff) {
|
||||
void import("@/plugins/built-in/gradeAnalytics/loadAnalyticsPage").then(
|
||||
(m) => m.loadAnalyticsPage(),
|
||||
);
|
||||
}
|
||||
finishLoad();
|
||||
break;
|
||||
case undefined:
|
||||
@@ -488,25 +491,37 @@ async function handleReports(node: Element): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function CheckNoticeTextColour(notice: any) {
|
||||
eventManager.register(
|
||||
"noticeAdded",
|
||||
{
|
||||
elementType: "div",
|
||||
className: "notice",
|
||||
parentElement: notice,
|
||||
},
|
||||
(node) => {
|
||||
var hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
||||
if (hex) {
|
||||
const hex1 = hex.slice(0, -1);
|
||||
var threshold = GetThresholdOfColor(hex1);
|
||||
if (settingsState.DarkMode && threshold < 100) {
|
||||
(node as HTMLElement).style.cssText = "--color: undefined;";
|
||||
function CheckNoticeTextColour(notice: Element) {
|
||||
const adjustNoticeColor = (node: Element) => {
|
||||
const hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
||||
if (hex) {
|
||||
const hex1 = hex.slice(0, -1);
|
||||
const threshold = GetThresholdOfColor(hex1);
|
||||
if (settingsState.DarkMode && threshold < 100) {
|
||||
(node as HTMLElement).style.cssText = "--color: undefined;";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const node of notice.querySelectorAll("div.notice")) {
|
||||
adjustNoticeColor(node);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of mutation.addedNodes) {
|
||||
if (!(added instanceof Element)) continue;
|
||||
if (added.matches("div.notice")) {
|
||||
adjustNoticeColor(added);
|
||||
}
|
||||
for (const node of added.querySelectorAll("div.notice")) {
|
||||
adjustNoticeColor(node);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(notice, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function watchForEngageLogin() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export { init as Monofile } from "./monofile";
|
||||
|
||||
export async function initializePlugins(): Promise<void> {
|
||||
const { pluginManager } = await import("./index");
|
||||
await pluginManager.startAllPlugins();
|
||||
}
|
||||
Reference in New Issue
Block a user