mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Merge branch 'global-search' into main
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semi": false
|
"semi": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// vite-plugin-inline-worker-dev.ts
|
||||||
|
import { Plugin } from 'vite'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import { build, transform } from 'esbuild'
|
||||||
|
|
||||||
|
export default function InlineWorkerDevPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'vite:inline-worker-dev',
|
||||||
|
async load(id) {
|
||||||
|
if (id.includes('?inlineWorker')) {
|
||||||
|
const [cleanPath] = id.split('?')
|
||||||
|
console.log('cleanPath', cleanPath)
|
||||||
|
const code = await fs.readFile(cleanPath, 'utf-8')
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [cleanPath],
|
||||||
|
bundle: true,
|
||||||
|
write: false,
|
||||||
|
platform: 'browser',
|
||||||
|
format: 'iife',
|
||||||
|
target: 'esnext',
|
||||||
|
})
|
||||||
|
|
||||||
|
const workerCode = result.outputFiles[0].text
|
||||||
|
|
||||||
|
const workerBlobCode = `
|
||||||
|
const code = ${JSON.stringify(workerCode)};
|
||||||
|
export default function InlineWorker() {
|
||||||
|
const blob = new Blob([code], { type: 'application/javascript' });
|
||||||
|
return new Worker(URL.createObjectURL(blob), { type: 'module' });
|
||||||
|
}
|
||||||
|
`
|
||||||
|
return workerBlobCode
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,16 +75,19 @@
|
|||||||
"@uiw/codemirror-extensions-color": "^4.23.10",
|
"@uiw/codemirror-extensions-color": "^4.23.10",
|
||||||
"@uiw/codemirror-theme-github": "^4.23.10",
|
"@uiw/codemirror-theme-github": "^4.23.10",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"client-vector-search": "../client-vector-search",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"color": "^5.0.0",
|
"color": "^5.0.0",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"embla-carousel-autoplay": "^8.5.2",
|
"embla-carousel-autoplay": "^8.5.2",
|
||||||
"embla-carousel-svelte": "^8.5.2",
|
"embla-carousel-svelte": "^8.5.2",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
|
"flexsearch": "^0.8.147",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"idb": "^8.0.2",
|
"idb": "^8.0.2",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"mathjs": "^14.4.0",
|
||||||
"million": "^3.1.11",
|
"million": "^3.1.11",
|
||||||
"motion": "^12.4.12",
|
"motion": "^12.4.12",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
|
|||||||
Vendored
+5
@@ -5,6 +5,11 @@ declare module '*.png';
|
|||||||
declare module '*.html';
|
declare module '*.html';
|
||||||
declare module '*.svelte';
|
declare module '*.svelte';
|
||||||
|
|
||||||
|
declare module '*?inlineWorker' {
|
||||||
|
const value: () => Worker;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "*.png?base64" {
|
declare module "*.png?base64" {
|
||||||
const value: string;
|
const value: string;
|
||||||
export default value;
|
export default value;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
aria-label="Color Picker Swatch"
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
style="background: {$settingsState.selectedColor}"
|
style="background: {$settingsState.selectedColor}"
|
||||||
class="w-16 h-8 rounded-md"
|
class="w-16 h-8 rounded-md"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
|
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
|
||||||
import Spinner from '../Spinner.svelte';
|
import Spinner from '../Spinner.svelte';
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
import Fuse from 'fuse.js';
|
import { Index } from 'flexsearch';
|
||||||
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
||||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
|
|
||||||
@@ -20,19 +20,12 @@
|
|||||||
let savedBackgrounds = $state<string[]>([]);
|
let savedBackgrounds = $state<string[]>([]);
|
||||||
let installingBackgrounds = $state<Set<string>>(new Set());
|
let installingBackgrounds = $state<Set<string>>(new Set());
|
||||||
let debugInfo = $state<string>('');
|
let debugInfo = $state<string>('');
|
||||||
|
let searchIndex = $state<Index | null>(null);
|
||||||
|
|
||||||
// New state variables
|
// New state variables
|
||||||
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
|
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
|
||||||
let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
|
let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
|
||||||
|
|
||||||
// Add Fuse.js options
|
|
||||||
const fuseOptions = {
|
|
||||||
keys: ['name', 'description'],
|
|
||||||
threshold: 0.4,
|
|
||||||
ignoreLocation: true
|
|
||||||
};
|
|
||||||
let fuse: Fuse<Background>;
|
|
||||||
|
|
||||||
// Existing functions
|
// Existing functions
|
||||||
const loadStore = async () => {
|
const loadStore = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -43,7 +36,19 @@
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
backgrounds = data.backgrounds;
|
backgrounds = data.backgrounds;
|
||||||
fuse = new Fuse(backgrounds, fuseOptions);
|
|
||||||
|
// Initialize FlexSearch index
|
||||||
|
const index = new Index({
|
||||||
|
tokenize: "forward",
|
||||||
|
preset: "score"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add backgrounds to the index
|
||||||
|
backgrounds.forEach((bg, i) => {
|
||||||
|
index.add(i, bg.name + " " + bg.description);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchIndex = index;
|
||||||
debugInfo = `Loaded ${backgrounds.length} backgrounds`;
|
debugInfo = `Loaded ${backgrounds.length} backgrounds`;
|
||||||
await loadSavedBackgrounds();
|
await loadSavedBackgrounds();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -74,14 +79,10 @@
|
|||||||
let filteredBackgrounds = $derived((() => {
|
let filteredBackgrounds = $derived((() => {
|
||||||
let filtered = backgrounds;
|
let filtered = backgrounds;
|
||||||
|
|
||||||
// Use Fuse.js search if there's a search term
|
// Use FlexSearch if there's a search term
|
||||||
if (searchTerm.trim()) {
|
if (searchTerm.trim() && searchIndex) {
|
||||||
// @ts-ignore
|
const results = searchIndex.search(searchTerm) as number[];
|
||||||
if (fuse) {
|
filtered = results.map(i => backgrounds[i]);
|
||||||
filtered = fuse.search(searchTerm).map((result: any) => result.item) ?? [];
|
|
||||||
} else {
|
|
||||||
filtered = backgrounds.filter(bg => bg.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply category filtering
|
// Apply category filtering
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { mount } from "svelte"
|
import { mount } from "svelte"
|
||||||
import type { ComponentType } from "svelte"
|
import type { SvelteComponent } from "svelte"
|
||||||
import style from './index.css?inline'
|
import style from './index.css?inline'
|
||||||
|
|
||||||
export default function renderSvelte(
|
export default function renderSvelte(
|
||||||
Component: ComponentType | any,
|
Component: SvelteComponent | any,
|
||||||
mountPoint: ShadowRoot | HTMLElement,
|
mountPoint: ShadowRoot | HTMLElement,
|
||||||
props: Record<string, any> = {},
|
props: Record<string, any> = {},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
|
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
|
||||||
<div class="flex items-center justify-between px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm">{Shortcut.name}</h2>
|
<h2 class="text-sm">{Shortcut.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full p-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Shortcut Name"
|
placeholder="Shortcut Name"
|
||||||
bind:value={newTitle}
|
bind:value={newTitle}
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full p-2 my-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
class="p-2 my-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="URL eg. https://google.com"
|
placeholder="URL eg. https://google.com"
|
||||||
bind:value={newURL}
|
bind:value={newURL}
|
||||||
@@ -142,9 +142,9 @@
|
|||||||
|
|
||||||
<!-- Custom Shortcuts Section -->
|
<!-- Custom Shortcuts Section -->
|
||||||
{#each $settingsState.customshortcuts as shortcut, index}
|
{#each $settingsState.customshortcuts as shortcut, index}
|
||||||
<div class="flex items-center justify-between px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
{shortcut.name}
|
{shortcut.name}
|
||||||
<button onclick={() => deleteCustomShortcut(index)}>
|
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -32,15 +32,7 @@
|
|||||||
],
|
],
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": ["*/*"],
|
"resources": ["*/*", "resources/*", "seqta/utils/migration/migrate.html", "plugins/built-in/globalSearch/*"],
|
||||||
"matches": ["*://*/*"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resources": ["resources/*"],
|
|
||||||
"matches": ["*://*/*"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resources": ["seqta/utils/migration/migrate.html"],
|
|
||||||
"matches": ["*://*/*"]
|
"matches": ["*://*/*"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,626 @@
|
|||||||
|
# client-vector-search
|
||||||
|
|
||||||
|
A client side vector search library that can embed, search, and cache. Works on the browser and server side.
|
||||||
|
|
||||||
|
It outperforms OpenAI's text-embedding-ada-002 and is way faster than Pinecone and other VectorDBs.
|
||||||
|
|
||||||
|
I'm the founder of [searchbase.app](https://searchbase.app) and we needed this for our product and customers. We'll be using this library in production. You can be sure it'll be maintained and improved.
|
||||||
|
|
||||||
|
- Embed documents using transformers by default: gte-small (~30mb).
|
||||||
|
- Calculate cosine similarity between embeddings.
|
||||||
|
- Create an index and search on the client side
|
||||||
|
- Cache vectors with browser caching support.
|
||||||
|
|
||||||
|
Lots of improvements are coming!
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Our goal is to build a super simple, fast vector search that works with couple hundred to thousands vectors. ~1k vectors per user covers 99% of the use cases.
|
||||||
|
|
||||||
|
We'll initially keep things super simple and sub 100ms
|
||||||
|
|
||||||
|
### TODOs
|
||||||
|
|
||||||
|
- [ ] add HNSW index that works on node and browser env, don't rely on hnsw binder libs
|
||||||
|
- [ ] add a proper testing suite and ci/cd for the lib
|
||||||
|
- [ ] simple health tests
|
||||||
|
- [ ] mock the @xenova/transformers for jest, it's not happy with it
|
||||||
|
- [ ] performance tests, recall, memory usage, cpu usage etc.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i client-vector-search
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
This library provides a plug-and-play solution for embedding and vector search. It's designed to be easy to use, efficient, and versatile. Here's a quick start guide:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { getEmbedding, EmbeddingIndex } from "client-vector-search";
|
||||||
|
|
||||||
|
// getEmbedding is an async function, so you need to use 'await' or '.then()' to get the result
|
||||||
|
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
|
||||||
|
|
||||||
|
// Each object should have an 'embedding' property of type number[]
|
||||||
|
const initialObjects = [
|
||||||
|
{ id: 1, name: "Apple", embedding: embedding },
|
||||||
|
{ id: 2, name: "Banana", embedding: await getEmbedding("Banana") },
|
||||||
|
{ id: 3, name: "Cheddar", embedding: await getEmbedding("Cheddar") },
|
||||||
|
{ id: 4, name: "Space", embedding: await getEmbedding("Space") },
|
||||||
|
{ id: 5, name: "database", embedding: await getEmbedding("database") },
|
||||||
|
];
|
||||||
|
const index = new EmbeddingIndex(initialObjects); // Creates an index
|
||||||
|
|
||||||
|
// The query should be an embedding of type number[]
|
||||||
|
const queryEmbedding = await getEmbedding("Fruit"); // Query embedding
|
||||||
|
const results = await index.search(queryEmbedding, { topK: 5 }); // Returns top similar objects
|
||||||
|
|
||||||
|
// specify the storage type
|
||||||
|
await index.saveIndex("indexedDB");
|
||||||
|
const results = await index.search([1, 2, 3], {
|
||||||
|
topK: 5,
|
||||||
|
useStorage: "indexedDB",
|
||||||
|
// storageOptions: { // use only if you overrode the defaults
|
||||||
|
// indexedDBName: 'clientVectorDB',
|
||||||
|
// indexedDBObjectStoreName: 'ClientEmbeddingStore',
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(results);
|
||||||
|
|
||||||
|
await index.deleteIndexedDB(); // if you overrode default, specify db name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trouble-shooting
|
||||||
|
|
||||||
|
### NextJS
|
||||||
|
|
||||||
|
To use it inside NextJS projects you'll need to update the `next.config.js` file to include the following:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
// Override the default webpack configuration
|
||||||
|
webpack: (config) => {
|
||||||
|
// See https://webpack.js.org/configuration/resolve/#resolvealias
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
sharp$: false,
|
||||||
|
"onnxruntime-node$": false,
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Model load after page is loaded
|
||||||
|
|
||||||
|
You can initialize the model before using it to generate embeddings. This will ensure that the model is loaded before you use it and provide a better UX.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { initializeModel } from "client-vector-search"
|
||||||
|
...
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
initializeModel();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Guide
|
||||||
|
|
||||||
|
This guide provides a step-by-step walkthrough of the library's main features. It covers everything from generating embeddings for a string to performing operations on the index such as adding, updating, and removing objects. It also includes instructions on how to save the index to a database and perform search operations within it.
|
||||||
|
|
||||||
|
Until we have a reference documentation, you can find all the methods and their usage in this guide. Each step is accompanied by a code snippet to illustrate the usage of the method in question. Make sure to follow along and try out the examples in your own environment to get a better understanding of how everything works.
|
||||||
|
|
||||||
|
Let's get started!
|
||||||
|
|
||||||
|
### Step 1: Generate Embeddings for String
|
||||||
|
|
||||||
|
Generate embeddings for a given string using the `getEmbedding` method.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: `getEmbedding` is asynchronous; make sure to use `await`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Calculate Cosine Similarity
|
||||||
|
|
||||||
|
Calculate the cosine similarity between two embeddings.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const similarity = cosineSimilarity(embedding1, embedding2, 6);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: Both embeddings should be of the same length.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Create an Index
|
||||||
|
|
||||||
|
Create an index with an initial array of objects. Each object must have an 'embedding' property.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const initialObjects = [...];
|
||||||
|
const index = new EmbeddingIndex(initialObjects);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Add to Index
|
||||||
|
|
||||||
|
Add an object to the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const objectToAdd = {
|
||||||
|
id: 6,
|
||||||
|
name: "Cat",
|
||||||
|
embedding: await getEmbedding("Cat"),
|
||||||
|
};
|
||||||
|
index.add(objectToAdd);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Update Index
|
||||||
|
|
||||||
|
Update an existing object in the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const vectorToUpdate = {
|
||||||
|
id: 6,
|
||||||
|
name: "Dog",
|
||||||
|
embedding: await getEmbedding("Dog"),
|
||||||
|
};
|
||||||
|
index.update({ id: 6 }, vectorToUpdate);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6: Remove from Index
|
||||||
|
|
||||||
|
Remove an object from the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
index.remove({ id: 6 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7: Retrieve from Index
|
||||||
|
|
||||||
|
Retrieve an object from the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const vector = index.get({ id: 1 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8: Search the Index
|
||||||
|
|
||||||
|
Search the index with a query embedding.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const queryEmbedding = await getEmbedding("Fruit");
|
||||||
|
const results = await index.search(queryEmbedding, { topK: 5 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9: Print the Index
|
||||||
|
|
||||||
|
Print the entire index to the console.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
index.printIndex();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 10: Save Index to IndexedDB (for browser)
|
||||||
|
|
||||||
|
Save the index to a persistent IndexedDB database. Note
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await index.saveIndex("indexedDB", {
|
||||||
|
DBName: "clientVectorDB",
|
||||||
|
objectStoreName: "ClientEmbeddingStore",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Important: Search in indexedDB
|
||||||
|
|
||||||
|
Perform a search operation in the IndexedDB.
|
||||||
|
|
||||||
|
````ts
|
||||||
|
const results = await index.search(queryEmbedding, {
|
||||||
|
topK: 5,
|
||||||
|
useStorage: "indexedDB",
|
||||||
|
storageOptions: { // only if you want to override the default options, defaults are below
|
||||||
|
indexedDBName: 'clientVectorDB',
|
||||||
|
indexedDBObjectStoreName: 'ClientEmbeddingStore'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Delete Database
|
||||||
|
To delete an entire database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await IndexedDbManager.deleteIndexedDB("clientVectorDB");
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Delete Object Store
|
||||||
|
|
||||||
|
To delete an object store from a database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await IndexedDbManager.deleteIndexedDBObjectStore(
|
||||||
|
"clientVectorDB",
|
||||||
|
"ClientEmbeddingStore",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Retrieve All Objects
|
||||||
|
|
||||||
|
To retrieve all objects from a specific object store.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const allObjects = await IndexedDbManager.getAllObjectsFromIndexedDB(
|
||||||
|
"clientVectorDB",
|
||||||
|
"ClientEmbeddingStore",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
# THE MAIN INDEX.TS FILE THAT YOU ARE IMPORTING FROM
|
||||||
|
|
||||||
|
```index.ts
|
||||||
|
const DEFAULT_TOP_K = 3;
|
||||||
|
|
||||||
|
interface Filter {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
import Cache from './cache';
|
||||||
|
import { IndexedDbManager } from './indexedDB';
|
||||||
|
import { cosineSimilarity } from './utils';
|
||||||
|
export { ExperimentalHNSWIndex } from './hnsw';
|
||||||
|
|
||||||
|
// uncomment if you want to test indexedDB implementation in node env for faster dev cycle
|
||||||
|
// import { IDBFactory } from 'fake-indexeddb';
|
||||||
|
// const indexedDB = new IDBFactory();
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
similarity: number;
|
||||||
|
object: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageOptions = 'indexedDB' | 'localStorage' | 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for search options in the EmbeddingIndex class.
|
||||||
|
* topK: The number of top similar items to return.
|
||||||
|
* filter: An optional filter to apply to the objects before searching.
|
||||||
|
* useStorage: A flag to indicate whether to use storage options like indexedDB or localStorage.
|
||||||
|
*/
|
||||||
|
interface SearchOptions {
|
||||||
|
topK?: number;
|
||||||
|
filter?: Filter;
|
||||||
|
useStorage?: StorageOptions;
|
||||||
|
storageOptions?: { indexedDBName: string; indexedDBObjectStoreName: string }; // TODO: generalize it to localStorage as well
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheInstance = Cache.getInstance();
|
||||||
|
|
||||||
|
let pipe: any;
|
||||||
|
let currentModel: string;
|
||||||
|
|
||||||
|
export const initializeModel = async (
|
||||||
|
model: string = 'Xenova/gte-small',
|
||||||
|
): Promise<void> => {
|
||||||
|
if (model !== currentModel) {
|
||||||
|
const transformersModule = await import('@xenova/transformers');
|
||||||
|
const pipeline = transformersModule.pipeline;
|
||||||
|
pipe = await pipeline('feature-extraction', model);
|
||||||
|
currentModel = model;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmbedding = async (
|
||||||
|
text: string,
|
||||||
|
precision: number = 7,
|
||||||
|
options = { pooling: 'mean', normalize: false },
|
||||||
|
model = 'Xenova/gte-small',
|
||||||
|
): Promise<number[]> => {
|
||||||
|
const cachedEmbedding = cacheInstance.get(text);
|
||||||
|
if (cachedEmbedding) {
|
||||||
|
return Promise.resolve(cachedEmbedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model !== currentModel) {
|
||||||
|
await initializeModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await pipe(text, options);
|
||||||
|
const roundedOutput = Array.from(output.data as number[]).map(
|
||||||
|
(value: number) => parseFloat(value.toFixed(precision)),
|
||||||
|
);
|
||||||
|
cacheInstance.set(text, roundedOutput);
|
||||||
|
return Array.from(roundedOutput);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EmbeddingIndex {
|
||||||
|
private objects: Filter[];
|
||||||
|
private keys: string[];
|
||||||
|
|
||||||
|
constructor(initialObjects?: Filter[]) {
|
||||||
|
// TODO: add support for options while creating index such as {... indexedDB: true, ...}
|
||||||
|
this.objects = [];
|
||||||
|
this.keys = [];
|
||||||
|
if (initialObjects && initialObjects.length > 0) {
|
||||||
|
initialObjects.forEach((obj) => this.validateAndAdd(obj));
|
||||||
|
if (initialObjects[0]) {
|
||||||
|
this.keys = Object.keys(initialObjects[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findVectorIndex(filter: Filter): number {
|
||||||
|
return this.objects.findIndex((object) =>
|
||||||
|
Object.keys(filter).every((key) => object[key] === filter[key]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateAndAdd(obj: Filter) {
|
||||||
|
if (!Array.isArray(obj.embedding) || obj.embedding.some(isNaN)) {
|
||||||
|
throw new Error(
|
||||||
|
'Object must have an embedding property of type number[]',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.keys.length === 0) {
|
||||||
|
this.keys = Object.keys(obj);
|
||||||
|
} else if (!this.keys.every((key) => key in obj)) {
|
||||||
|
throw new Error(
|
||||||
|
'Object must have the same properties as the initial objects',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.objects.push(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(obj: Filter) {
|
||||||
|
this.validateAndAdd(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to update an existing vector in the index
|
||||||
|
update(filter: Filter, vector: Filter) {
|
||||||
|
const index = this.findVectorIndex(filter);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Vector not found');
|
||||||
|
}
|
||||||
|
if (vector.hasOwnProperty('embedding')) {
|
||||||
|
// Validate and add the new vector
|
||||||
|
this.validateAndAdd(vector);
|
||||||
|
}
|
||||||
|
// Replace the old vector with the new one
|
||||||
|
this.objects[index] = Object.assign(this.objects[index] as Filter, vector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to remove a vector from the index
|
||||||
|
remove(filter: Filter) {
|
||||||
|
const index = this.findVectorIndex(filter);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Vector not found');
|
||||||
|
}
|
||||||
|
// Remove the vector from the index
|
||||||
|
this.objects.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to remove multiple vectors from the index
|
||||||
|
removeBatch(filters: Filter[]) {
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
const index = this.findVectorIndex(filter);
|
||||||
|
if (index !== -1) {
|
||||||
|
// Remove the vector from the index
|
||||||
|
this.objects.splice(index, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to retrieve a vector from the index
|
||||||
|
get(filter: Filter) {
|
||||||
|
const vector = this.objects[this.findVectorIndex(filter)];
|
||||||
|
return vector || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
// Returns the size of the index
|
||||||
|
return this.objects.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.objects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
queryEmbedding: number[],
|
||||||
|
options: SearchOptions = {
|
||||||
|
topK: 3,
|
||||||
|
useStorage: 'none',
|
||||||
|
storageOptions: {
|
||||||
|
indexedDBName: 'clientVectorDB',
|
||||||
|
indexedDBObjectStoreName: 'ClientEmbeddingStore',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
const topK = options.topK || DEFAULT_TOP_K;
|
||||||
|
const filter = options.filter || {};
|
||||||
|
const useStorage = options.useStorage || 'none';
|
||||||
|
|
||||||
|
if (useStorage === 'indexedDB') {
|
||||||
|
const DBname = options.storageOptions?.indexedDBName || 'clientVectorDB';
|
||||||
|
const objectStoreName =
|
||||||
|
options.storageOptions?.indexedDBObjectStoreName ||
|
||||||
|
'ClientEmbeddingStore';
|
||||||
|
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
console.error('IndexedDB is not supported');
|
||||||
|
throw new Error('IndexedDB is not supported');
|
||||||
|
}
|
||||||
|
const results = await this.loadAndSearchFromIndexedDB(
|
||||||
|
DBname,
|
||||||
|
objectStoreName,
|
||||||
|
queryEmbedding,
|
||||||
|
topK,
|
||||||
|
filter,
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
} else {
|
||||||
|
// Compute similarities
|
||||||
|
const similarities = this.objects
|
||||||
|
.filter((object) =>
|
||||||
|
Object.keys(filter).every((key) => object[key] === filter[key]),
|
||||||
|
)
|
||||||
|
.map((obj) => ({
|
||||||
|
similarity: cosineSimilarity(queryEmbedding, obj.embedding),
|
||||||
|
object: obj,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by similarity and return topK results
|
||||||
|
return similarities
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, topK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printIndex() {
|
||||||
|
console.log('Index Content:');
|
||||||
|
this.objects.forEach((obj, idx) => {
|
||||||
|
console.log(`Item ${idx + 1}:`, obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveIndex(
|
||||||
|
storageType: string,
|
||||||
|
options: { DBName: string; objectStoreName: string } = {
|
||||||
|
DBName: 'clientVectorDB',
|
||||||
|
objectStoreName: 'ClientEmbeddingStore',
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (storageType === 'indexedDB') {
|
||||||
|
await this.saveToIndexedDB(options.DBName, options.objectStoreName);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported storage type: ${storageType} \n Supported storage types: "indexedDB"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveToIndexedDB(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
console.error('IndexedDB is not defined');
|
||||||
|
throw new Error('IndexedDB is not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.objects || this.objects.length === 0) {
|
||||||
|
throw new Error('Index is empty. Nothing to save');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
await db.addToIndexedDB(this.objects);
|
||||||
|
console.log(
|
||||||
|
`Index saved to database '${DBname}' object store '${objectStoreName}'`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving index to database:', error);
|
||||||
|
throw new Error('Error saving index to database');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAndSearchFromIndexedDB(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
queryEmbedding: number[],
|
||||||
|
topK: number,
|
||||||
|
filter: { [key: string]: any },
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
const generator = db.dbGenerator();
|
||||||
|
const results: { similarity: number; object: any }[] = [];
|
||||||
|
|
||||||
|
for await (const record of generator) {
|
||||||
|
if (Object.keys(filter).every((key) => record[key] === filter[key])) {
|
||||||
|
const similarity = cosineSimilarity(queryEmbedding, record.embedding);
|
||||||
|
results.push({ similarity, object: record });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
return results.slice(0, topK);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIndexedDB(DBname: string = 'clientVectorDB'): Promise<void> {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
console.error('IndexedDB is not defined');
|
||||||
|
throw new Error('IndexedDB is not supported');
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.deleteDatabase(DBname);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Database '${DBname}' deleted`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = (event) => {
|
||||||
|
console.error('Failed to delete database', event);
|
||||||
|
reject(new Error('Failed to delete database'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIndexedDBObjectStore(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.deleteIndexedDBObjectStoreFromDB(DBname, objectStoreName);
|
||||||
|
console.log(
|
||||||
|
`Object store '${objectStoreName}' deleted from database '${DBname}'`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting object store:', error);
|
||||||
|
throw new Error('Error deleting object store');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllObjectsFromIndexedDB(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
): Promise<any[]> {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
const objects: any[] = [];
|
||||||
|
for await (const record of db.dbGenerator()) {
|
||||||
|
objects.push(record);
|
||||||
|
}
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
|
||||||
|
import type { DynamicContentItem } from '../utils/dynamicItems';
|
||||||
|
import type { FuseResultMatch } from '../core/types';
|
||||||
|
|
||||||
|
const { item, isSelected, searchTerm, matches } = $props<{
|
||||||
|
item: DynamicContentItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
||||||
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center w-full">
|
||||||
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{item.metadata?.icon || '\ue924'}</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightMatch(item.text, searchTerm, matches))}
|
||||||
|
</span>
|
||||||
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if item.content}
|
||||||
|
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightSnippet(item.content, searchTerm, matches))}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.highlight) {
|
||||||
|
background-color: rgba(255, 213, 0, 0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
margin: 0 -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :global(.highlight) {
|
||||||
|
background-color: rgba(255, 230, 100, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
|
import { unitFullNames } from './unitMap';
|
||||||
|
import * as math from 'mathjs';
|
||||||
|
|
||||||
|
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
hasResult: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let result = $state<string | null>(null);
|
||||||
|
let isCalculating = $state(false);
|
||||||
|
let inputUnit = $state<string>('');
|
||||||
|
let outputUnit = $state<string>('');
|
||||||
|
|
||||||
|
function detectUnit(expression: string): string {
|
||||||
|
try {
|
||||||
|
const unit = math.unit(expression);
|
||||||
|
if (unit) {
|
||||||
|
// Get the base unit name
|
||||||
|
const unitStr = unit.formatUnits();
|
||||||
|
return unitFullNames[unitStr] || unitStr;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not a unit or invalid expression
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the input with debounce to avoid unnecessary calculations
|
||||||
|
const processInput = (input: string) => {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!input.trim() ||
|
||||||
|
(input.trim().length <= 2 && !/\d/.test(input))
|
||||||
|
) {
|
||||||
|
result = null;
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCalculating = true;
|
||||||
|
|
||||||
|
// Let mathjs handle everything
|
||||||
|
const evaluated = math.evaluate(input.replace('**', '^'));
|
||||||
|
|
||||||
|
// Format the result
|
||||||
|
if (evaluated !== undefined) {
|
||||||
|
if (math.typeOf(evaluated) === 'Unit') {
|
||||||
|
// Handle unit conversion results
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
inputUnit = detectUnit(input);
|
||||||
|
outputUnit = detectUnit(result);
|
||||||
|
} else if (typeof evaluated === 'number') {
|
||||||
|
// Handle regular numbers
|
||||||
|
if (math.round(evaluated) === evaluated) {
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
} else {
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
}
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
} else {
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
}
|
||||||
|
dispatch('hasResult', result);
|
||||||
|
} else {
|
||||||
|
result = null;
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If mathjs throws an error, this isn't a valid expression
|
||||||
|
result = null;
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
} finally {
|
||||||
|
isCalculating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
processInput(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if result !== null}
|
||||||
|
<div class="p-2">
|
||||||
|
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
|
||||||
|
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
|
||||||
|
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
|
||||||
|
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
|
||||||
|
style="--char-count: {searchTerm?.length || 10}; font-size: min(2.5rem, max(1rem, calc(35vw / var(--char-count, 10))))">
|
||||||
|
{searchTerm}
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
|
||||||
|
{inputUnit || 'Question'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col flex-shrink-0 justify-center items-center w-12">
|
||||||
|
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
|
||||||
|
<div class="text-2xl text-zinc-900 dark:text-zinc-100">
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isCalculating}
|
||||||
|
<div class="flex flex-col flex-1 items-center py-4 pr-4 min-w-0">
|
||||||
|
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
|
||||||
|
style="--char-count: {result?.length || 10}; font-size: min(2.5rem, max(1rem, calc(30vw / var(--char-count, 10))))">
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
|
||||||
|
{outputUnit || 'Result'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-6 h-6 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
|
import { fade, scale } from 'svelte/transition';
|
||||||
|
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 { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
|
||||||
|
import Fuse from 'fuse.js';
|
||||||
|
import Calculator from './Calculator.svelte';
|
||||||
|
import { actionMap } from '../indexing/actions';
|
||||||
|
import type { IndexItem, HydratedIndexItem } from '../indexing/types';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
const {
|
||||||
|
transparencyEffects,
|
||||||
|
showRecentFirst
|
||||||
|
} = $props<{
|
||||||
|
transparencyEffects: boolean,
|
||||||
|
showRecentFirst: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let commandsFuse = $state<Fuse<StaticCommandItem>>();
|
||||||
|
let dynamicContentFuse = $state<Fuse<HydratedIndexItem>>();
|
||||||
|
|
||||||
|
const dynamicIdToItemMap = $state(new Map<string, HydratedIndexItem>());
|
||||||
|
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
||||||
|
|
||||||
|
let isIndexing = $state(false);
|
||||||
|
let completedJobs = $state(0);
|
||||||
|
let totalJobs = $state(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const progressHandler = (event: CustomEvent) => {
|
||||||
|
const { completed, total, indexing } = event.detail;
|
||||||
|
completedJobs = completed;
|
||||||
|
totalJobs = total;
|
||||||
|
isIndexing = indexing;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
|
||||||
|
const itemsUpdatedHandler = () => {
|
||||||
|
console.log('Search Bar received items-updated event, re-indexing...');
|
||||||
|
setupSearchIndexes();
|
||||||
|
performSearch();
|
||||||
|
};
|
||||||
|
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupSearchIndexes() {
|
||||||
|
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
|
||||||
|
|
||||||
|
commandsFuse = cfuse;
|
||||||
|
dynamicContentFuse = dfuse;
|
||||||
|
|
||||||
|
dynamicIdToItemMap.clear();
|
||||||
|
commandIdToItemMap.clear();
|
||||||
|
|
||||||
|
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
|
||||||
|
commands.forEach(item => commandIdToItemMap.set(item.id, item));
|
||||||
|
|
||||||
|
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
|
||||||
|
}
|
||||||
|
let commandPalleteOpen = $state(false);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let searchbar = $state<HTMLInputElement>();
|
||||||
|
let combinedResults = $state<CombinedResult[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let prevSearchTerm = $state('');
|
||||||
|
let calculatorResult = $state<string | null>(null);
|
||||||
|
|
||||||
|
const updateCalculatorState = (hasResult: string | null) => {
|
||||||
|
calculatorResult = hasResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setupSearchIndexes();
|
||||||
|
|
||||||
|
// @ts-ignore - Intentionally adding to window
|
||||||
|
window.setCommandPalleteOpen = (open: boolean) => {
|
||||||
|
commandPalleteOpen = open;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
commandPalleteOpen = true;
|
||||||
|
tick().then(() => searchbar?.focus());
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const performSearch = async () => {
|
||||||
|
isLoading = true;
|
||||||
|
selectedIndex = 0;
|
||||||
|
|
||||||
|
const term = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (commandsFuse && dynamicContentFuse) {
|
||||||
|
combinedResults = await doSearch(
|
||||||
|
term,
|
||||||
|
commandsFuse,
|
||||||
|
dynamicContentFuse,
|
||||||
|
commandIdToItemMap,
|
||||||
|
dynamicIdToItemMap,
|
||||||
|
showRecentFirst
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
combinedResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedPerformSearch = debounce(performSearch, 10);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (commandPalleteOpen) {
|
||||||
|
if (searchTerm === '') {
|
||||||
|
performSearch();
|
||||||
|
} else {
|
||||||
|
debouncedPerformSearch();
|
||||||
|
}
|
||||||
|
tick().then(() => searchbar?.focus());
|
||||||
|
} else {
|
||||||
|
searchTerm = '';
|
||||||
|
selectedIndex = 0;
|
||||||
|
prevSearchTerm = '';
|
||||||
|
combinedResults = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (combinedResults.length === 0 && calculatorResult && commandPalleteOpen) {
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectNext = () => {
|
||||||
|
const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1;
|
||||||
|
if (selectedIndex < maxIndex) {
|
||||||
|
selectedIndex++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPrev = () => {
|
||||||
|
if (selectedIndex > 0) {
|
||||||
|
selectedIndex--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function executeItemAction(item: StaticCommandItem | HydratedIndexItem) {
|
||||||
|
if ('action' in item && typeof item.action === 'function') {
|
||||||
|
(item as StaticCommandItem).action();
|
||||||
|
} else if ('actionId' in item && item.actionId && actionMap[item.actionId]) {
|
||||||
|
actionMap[item.actionId](item as IndexItem);
|
||||||
|
}
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeSelected = () => {
|
||||||
|
if (calculatorResult && selectedIndex === 0) {
|
||||||
|
navigator.clipboard.writeText(calculatorResult);
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
} else {
|
||||||
|
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
|
||||||
|
const result = combinedResults[resultIndex];
|
||||||
|
if (result?.item) {
|
||||||
|
executeItemAction(result.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyNav = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectNext();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectPrev();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
executeSelected();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if commandPalleteOpen}
|
||||||
|
<div role="dialog" aria-modal="true" class={settingsState.DarkMode ? 'dark' : ''}>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[50000] bg-zinc-900/40 dark:bg-black/60"
|
||||||
|
transition:fade={{ duration: 150, easing: quintOut }}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-[50000] flex justify-center place-items-start p-8 sm:p-6 md:p-8 select-none"
|
||||||
|
onclick={() => commandPalleteOpen = false}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (commandPalleteOpen = false)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-2xl overflow-clip rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur' : 'bg-white dark:bg-zinc-900' }"
|
||||||
|
transition:scale={{ duration: 100, start: 0.95, opacity: 0, easing: circOut }}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
|
||||||
|
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
|
||||||
|
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
|
||||||
|
{'\ueca5'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={searchbar}
|
||||||
|
bind:value={searchTerm}
|
||||||
|
onkeydown={handleKeyNav}
|
||||||
|
class="pr-4 pl-12 w-full h-10 text-lg bg-transparent border-0 outline-none placeholder-zinc-400 text-zinc-700 dark:placeholder-zinc-500 dark:text-white focus:ring-0 sm:text-xl"
|
||||||
|
placeholder="Search..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
|
||||||
|
<Calculator
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
isSelected={selectedIndex === 0}
|
||||||
|
on:hasResult={(e) => updateCalculatorState(e.detail)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if combinedResults.length > 0}
|
||||||
|
{#each combinedResults as result, i (result.id)}
|
||||||
|
{@const isSelected = selectedIndex === (calculatorResult ? i + 1 : i)}
|
||||||
|
{@const item = result.item}
|
||||||
|
<li>
|
||||||
|
{#if result.type === 'command'}
|
||||||
|
{@const staticItem = item as StaticCommandItem}
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
||||||
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
||||||
|
onclick={() => executeItemAction(staticItem)}
|
||||||
|
>
|
||||||
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{staticItem.icon}</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
{@html highlightMatch(staticItem.text, searchTerm, result.matches)}
|
||||||
|
</span>
|
||||||
|
{#if staticItem.keybindLabel}
|
||||||
|
<div class="flex-none ml-auto">
|
||||||
|
{@render Shortcut({ text: '', keybind: staticItem.keybindLabel })}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else if result.type === 'dynamic'}
|
||||||
|
{@const dynamicItem = item as HydratedIndexItem}
|
||||||
|
{#if dynamicItem.renderComponent}
|
||||||
|
<dynamicItem.renderComponent
|
||||||
|
item={dynamicItem}
|
||||||
|
isSelected={isSelected}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
matches={result.matches}
|
||||||
|
on:click={() => executeItemAction(dynamicItem)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
||||||
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
||||||
|
onclick={() => executeItemAction(dynamicItem)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center w-full">
|
||||||
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{dynamicItem.metadata?.icon || '\ue924'}</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightMatch(dynamicItem.text, searchTerm, result.matches))}
|
||||||
|
</span>
|
||||||
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{dynamicItem.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if dynamicItem.content}
|
||||||
|
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightSnippet(dynamicItem.content, searchTerm, result.matches))}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else if !calculatorResult}
|
||||||
|
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="mx-auto w-8 h-8 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
|
||||||
|
<p class="mt-4 text-lg dark:text-zinc-300">Searching...</p>
|
||||||
|
{:else}
|
||||||
|
<svg class="mx-auto w-8 h-8 text-opacity-40 dark:text-opacity-60" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||||
|
</svg>
|
||||||
|
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
<div class="px-3 py-2 w-full border-t border-zinc-900/5 dark:border-zinc-100/5 bg-white/5">
|
||||||
|
{#if combinedResults.length > 0 || calculatorResult}
|
||||||
|
<div class="flex justify-between items-center h-5 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
{#if !calculatorResult}
|
||||||
|
{#if selectedIndex >= 0 && selectedIndex < combinedResults.length}
|
||||||
|
{@const item = combinedResults[selectedIndex].item}
|
||||||
|
{#if 'keybind' in item && item.keybind}
|
||||||
|
{@render Shortcut({ text: 'Shortcut', keybind: [ ...(item?.keybindLabel ?? []) ] })}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
||||||
|
{#if calculatorResult && selectedIndex === 0}
|
||||||
|
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
|
||||||
|
{:else}
|
||||||
|
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isIndexing}
|
||||||
|
<div class="inset-x-0 top-0">
|
||||||
|
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
Indexing
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
|
||||||
|
<div
|
||||||
|
class="h-full bg-blue-500 transition-all duration-300 ease-out"
|
||||||
|
style="width: {(completedJobs / totalJobs) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet Shortcut({ text, keybind }: { text: string, keybind: string[] }) }
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
{#each keybind as key}
|
||||||
|
<kbd class="px-1 py-0.5 text-[0.8rem] text-center align-middle rounded min-w-6 bg-zinc-100 dark:bg-zinc-100/10">{key}</kbd>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.highlight) {
|
||||||
|
background-color: rgba(200, 200, 200, 0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
margin: 0 -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :global(.highlight) {
|
||||||
|
background-color: rgba(79, 79, 79, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
export const unitFullNames: Record<string, string> = {
|
||||||
|
// --- Length ---
|
||||||
|
m: "Meters",
|
||||||
|
km: "Kilometers",
|
||||||
|
cm: "Centimeters",
|
||||||
|
mm: "Millimeters",
|
||||||
|
µm: "Micrometers",
|
||||||
|
nm: "Nanometers",
|
||||||
|
pm: "Picometers",
|
||||||
|
fm: "Femtometers",
|
||||||
|
am: "Attometers",
|
||||||
|
zm: "Zeptometers",
|
||||||
|
ym: "Yoctometers",
|
||||||
|
mi: "Miles",
|
||||||
|
yd: "Yards",
|
||||||
|
ft: "Feet",
|
||||||
|
in: "Inches",
|
||||||
|
nmi: "Nautical Miles",
|
||||||
|
angstrom: "Angstroms",
|
||||||
|
au: "Astronomical Units",
|
||||||
|
ly: "Light Years",
|
||||||
|
pc: "Parsecs",
|
||||||
|
|
||||||
|
// --- Mass ---
|
||||||
|
kg: "Kilograms",
|
||||||
|
g: "Grams",
|
||||||
|
mg: "Milligrams",
|
||||||
|
µg: "Micrograms",
|
||||||
|
ng: "Nanograms",
|
||||||
|
lb: "Pounds",
|
||||||
|
oz: "Ounces",
|
||||||
|
ton: "Tons (Imperial)",
|
||||||
|
tonne: "Tonnes (Metric)",
|
||||||
|
slug: "Slugs",
|
||||||
|
stone: "Stones",
|
||||||
|
|
||||||
|
// --- Time ---
|
||||||
|
s: "Seconds",
|
||||||
|
ms: "Milliseconds",
|
||||||
|
µs: "Microseconds",
|
||||||
|
ns: "Nanoseconds",
|
||||||
|
ps: "Picoseconds",
|
||||||
|
min: "Minutes",
|
||||||
|
h: "Hours",
|
||||||
|
day: "Days",
|
||||||
|
week: "Weeks",
|
||||||
|
month: "Months (30 days)",
|
||||||
|
year: "Years (365 days)",
|
||||||
|
fortnight: "Fortnights",
|
||||||
|
|
||||||
|
// --- Temperature ---
|
||||||
|
K: "Kelvin",
|
||||||
|
degC: "Degrees Celsius",
|
||||||
|
degF: "Degrees Fahrenheit",
|
||||||
|
degR: "Degrees Rankine",
|
||||||
|
|
||||||
|
// --- Volume ---
|
||||||
|
"m³": "Cubic Meters",
|
||||||
|
"cm³": "Cubic Centimeters",
|
||||||
|
"mm³": "Cubic Millimeters",
|
||||||
|
l: "Liters",
|
||||||
|
ml: "Milliliters",
|
||||||
|
gal: "Gallons (US)",
|
||||||
|
qt: "Quarts (US)",
|
||||||
|
pt: "Pints (US)",
|
||||||
|
cup: "Cups (US)",
|
||||||
|
floz: "Fluid Ounces (US)",
|
||||||
|
tbsp: "Tablespoons (US)",
|
||||||
|
tsp: "Teaspoons (US)",
|
||||||
|
|
||||||
|
// --- Area ---
|
||||||
|
"m²": "Square Meters",
|
||||||
|
"km²": "Square Kilometers",
|
||||||
|
"cm²": "Square Centimeters",
|
||||||
|
"mm²": "Square Millimeters",
|
||||||
|
ha: "Hectares",
|
||||||
|
acre: "Acres",
|
||||||
|
"ft²": "Square Feet",
|
||||||
|
"in²": "Square Inches",
|
||||||
|
"mi²": "Square Miles",
|
||||||
|
|
||||||
|
// --- Speed ---
|
||||||
|
"m/s": "Meters per Second",
|
||||||
|
"km/h": "Kilometers per Hour",
|
||||||
|
mph: "Miles per Hour",
|
||||||
|
knot: "Knots",
|
||||||
|
|
||||||
|
// --- Acceleration ---
|
||||||
|
"m/s²": "Meters per Second Squared",
|
||||||
|
|
||||||
|
// --- Force ---
|
||||||
|
N: "Newtons",
|
||||||
|
lbf: "Pound-Force",
|
||||||
|
dyn: "Dynes",
|
||||||
|
|
||||||
|
// --- Energy ---
|
||||||
|
J: "Joules",
|
||||||
|
kJ: "Kilojoules",
|
||||||
|
cal: "Calories",
|
||||||
|
kcal: "Kilocalories",
|
||||||
|
Wh: "Watt Hours",
|
||||||
|
kWh: "Kilowatt Hours",
|
||||||
|
BTU: "British Thermal Units",
|
||||||
|
erg: "Ergs",
|
||||||
|
eV: "Electronvolts",
|
||||||
|
|
||||||
|
// --- Power ---
|
||||||
|
W: "Watts",
|
||||||
|
kW: "Kilowatts",
|
||||||
|
MW: "Megawatts",
|
||||||
|
GW: "Gigawatts",
|
||||||
|
hp: "Horsepower",
|
||||||
|
|
||||||
|
// --- Pressure ---
|
||||||
|
Pa: "Pascals",
|
||||||
|
kPa: "Kilopascals",
|
||||||
|
bar: "Bar",
|
||||||
|
atm: "Atmospheres",
|
||||||
|
psi: "Pounds per Square Inch",
|
||||||
|
torr: "Torr",
|
||||||
|
mmHg: "Millimeters of Mercury",
|
||||||
|
|
||||||
|
// --- Frequency ---
|
||||||
|
Hz: "Hertz",
|
||||||
|
kHz: "Kilohertz",
|
||||||
|
MHz: "Megahertz",
|
||||||
|
GHz: "Gigahertz",
|
||||||
|
THz: "Terahertz",
|
||||||
|
|
||||||
|
// --- Electric ---
|
||||||
|
V: "Volts",
|
||||||
|
A: "Amperes",
|
||||||
|
C: "Coulombs",
|
||||||
|
Ω: "Ohms",
|
||||||
|
F: "Farads",
|
||||||
|
S: "Siemens",
|
||||||
|
H: "Henries",
|
||||||
|
Wb: "Webers",
|
||||||
|
T: "Teslas",
|
||||||
|
lx: "Lux",
|
||||||
|
|
||||||
|
// --- Angle & Rotation ---
|
||||||
|
rad: "Radians",
|
||||||
|
deg: "Degrees",
|
||||||
|
grad: "Gradians",
|
||||||
|
cycle: "Cycles",
|
||||||
|
turn: "Turns",
|
||||||
|
rev: "Revolutions",
|
||||||
|
|
||||||
|
// --- Charge & Capacitance ---
|
||||||
|
e: "Elementary Charges",
|
||||||
|
|
||||||
|
// --- Magnetic & Light ---
|
||||||
|
lm: "Lumens",
|
||||||
|
ph: "Photons",
|
||||||
|
|
||||||
|
// --- Miscellaneous / Dimensionless ---
|
||||||
|
"%": "Percent",
|
||||||
|
ppm: "Parts per Million",
|
||||||
|
ppb: "Parts per Billion",
|
||||||
|
pptr: "Parts per Trillion",
|
||||||
|
dB: "Decibels",
|
||||||
|
bit: "Bits",
|
||||||
|
byte: "Bytes",
|
||||||
|
|
||||||
|
// --- Digital Storage ---
|
||||||
|
b: "Bits",
|
||||||
|
B: "Bytes",
|
||||||
|
kb: "Kilobits",
|
||||||
|
kB: "Kilobytes",
|
||||||
|
Mb: "Megabits",
|
||||||
|
MB: "Megabytes",
|
||||||
|
Gb: "Gigabits",
|
||||||
|
GB: "Gigabytes",
|
||||||
|
Tb: "Terabits",
|
||||||
|
TB: "Terabytes",
|
||||||
|
|
||||||
|
// --- Currency ---
|
||||||
|
USD: "US Dollars",
|
||||||
|
EUR: "Euros",
|
||||||
|
GBP: "British Pounds",
|
||||||
|
AUD: "Australian Dollars",
|
||||||
|
CAD: "Canadian Dollars",
|
||||||
|
CHF: "Swiss Francs",
|
||||||
|
JPY: "Japanese Yen",
|
||||||
|
CNY: "Chinese Yuan",
|
||||||
|
INR: "Indian Rupees",
|
||||||
|
NZD: "New Zealand Dollars",
|
||||||
|
SEK: "Swedish Krona",
|
||||||
|
NOK: "Norwegian Krone",
|
||||||
|
SGD: "Singapore Dollars",
|
||||||
|
HKD: "Hong Kong Dollars",
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
|
|
||||||
|
export interface BaseCommandItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
icon: string;
|
||||||
|
action: () => void;
|
||||||
|
keywords?: string[];
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticCommandItem extends BaseCommandItem {
|
||||||
|
keybind?: string[];
|
||||||
|
keybindLabel?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticCommands: StaticCommandItem[] = [
|
||||||
|
{
|
||||||
|
id: "home",
|
||||||
|
icon: "\ueb4c",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Home",
|
||||||
|
keybind: ["alt+h"],
|
||||||
|
keybindLabel: ["Alt", "H"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/home";
|
||||||
|
loadHomePage();
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "messages",
|
||||||
|
icon: "\uebfd",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Direct Messages",
|
||||||
|
keybind: ["alt+m"],
|
||||||
|
keybindLabel: ["Alt", "M"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/messages";
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timetable",
|
||||||
|
icon: "\ue9cd",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Timetable",
|
||||||
|
keybind: ["alt+t"],
|
||||||
|
keybindLabel: ["Alt", "T"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/timetable";
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assessments",
|
||||||
|
icon: "\ueac3",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Assessments",
|
||||||
|
keybind: ["alt+a"],
|
||||||
|
keybindLabel: ["Alt", "A"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/assessments";
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toggle-dark-mode",
|
||||||
|
icon: "\uecfe",
|
||||||
|
category: "action",
|
||||||
|
text: "Toggle Dark Mode",
|
||||||
|
action: () => (settingsState.DarkMode = !settingsState.DarkMode),
|
||||||
|
priority: 2,
|
||||||
|
keywords: ["theme", "appearance"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the predefined list of static commands.
|
||||||
|
*/
|
||||||
|
export const getStaticCommands = (): StaticCommandItem[] => {
|
||||||
|
return [...staticCommands];
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
|
import { BasePlugin } from "@/plugins/core/settings";
|
||||||
|
import {
|
||||||
|
booleanSetting,
|
||||||
|
defineSettings,
|
||||||
|
Setting,
|
||||||
|
stringSetting,
|
||||||
|
} from "@/plugins/core/settingsHelpers";
|
||||||
|
import styles from "./styles.css?inline";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import { runIndexing } from "../indexing/indexer";
|
||||||
|
import { initVectorSearch } from "../search/vector/vectorSearch";
|
||||||
|
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
||||||
|
|
||||||
|
const settings = defineSettings({
|
||||||
|
searchHotkey: stringSetting({
|
||||||
|
default: "ctrl+k",
|
||||||
|
title: "Search Hotkey",
|
||||||
|
description: "Keyboard shortcut to open the search (cmd on Mac)",
|
||||||
|
}),
|
||||||
|
showRecentFirst: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Show Recent First",
|
||||||
|
description: "Sort dynamic content by most recent first",
|
||||||
|
}),
|
||||||
|
transparencyEffects: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Transparency Effects",
|
||||||
|
description: "Enable transparency effects for the search bar",
|
||||||
|
}),
|
||||||
|
runIndexingOnLoad: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Index on Page Load",
|
||||||
|
description: "Run content indexing when SEQTA loads",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.searchHotkey)
|
||||||
|
searchHotkey!: string;
|
||||||
|
|
||||||
|
@Setting(settings.showRecentFirst)
|
||||||
|
showRecentFirst!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.transparencyEffects)
|
||||||
|
transparencyEffects!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.runIndexingOnLoad)
|
||||||
|
runIndexingOnLoad!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsInstance = new GlobalSearchPlugin();
|
||||||
|
|
||||||
|
const globalSearchPlugin: Plugin<typeof settings> = {
|
||||||
|
id: "global-search",
|
||||||
|
name: "Global Search",
|
||||||
|
description: "Quick search for everything in SEQTA",
|
||||||
|
version: "1.0.0",
|
||||||
|
settings: settingsInstance.settings,
|
||||||
|
disableToggle: true,
|
||||||
|
styles: styles,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
const appRef = { current: null };
|
||||||
|
|
||||||
|
initVectorSearch();
|
||||||
|
|
||||||
|
if (api.settings.runIndexingOnLoad) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
await runIndexing();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.querySelector("#title");
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
mountSearchBar(title, api, appRef);
|
||||||
|
} else {
|
||||||
|
await waitForElm("#title", true, 100, 60);
|
||||||
|
mountSearchBar(document.querySelector("#title") as Element, api, appRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupSearchBar(appRef);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default globalSearchPlugin;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import renderSvelte from "@/interface/main";
|
||||||
|
import SearchBar from "../components/SearchBar.svelte";
|
||||||
|
import { unmount } from "svelte";
|
||||||
|
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||||
|
|
||||||
|
export function mountSearchBar(
|
||||||
|
titleElement: Element,
|
||||||
|
api: any,
|
||||||
|
appRef: { current: any }
|
||||||
|
) {
|
||||||
|
if (titleElement.querySelector(".search-trigger")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchButton = document.createElement("div");
|
||||||
|
searchButton.className = "search-trigger";
|
||||||
|
searchButton.innerHTML = /* html */ `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
<p>Quick search...</p>
|
||||||
|
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">⌘K</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
titleElement.appendChild(searchButton);
|
||||||
|
|
||||||
|
const searchRoot = document.createElement("div");
|
||||||
|
document.body.appendChild(searchRoot);
|
||||||
|
const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
searchButton.addEventListener("click", () => {
|
||||||
|
// @ts-ignore - Intentionally adding to window
|
||||||
|
window.setCommandPalleteOpen(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||||
|
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||||
|
showRecentFirst: api.settings.showRecentFirst,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error rendering Svelte component:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupSearchBar(appRef: { current: any }) {
|
||||||
|
const searchButton = document.querySelector(".search-trigger");
|
||||||
|
const searchRoot = document.querySelector(".global-search-root");
|
||||||
|
if (searchButton) searchButton.remove();
|
||||||
|
if (searchRoot) searchRoot.remove();
|
||||||
|
|
||||||
|
// Clean up workers
|
||||||
|
VectorWorkerManager.getInstance().terminate();
|
||||||
|
unmount(appRef.current);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.search-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
margin-left: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-right: auto !important;
|
||||||
|
padding: 3px 12px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 48px;
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 32px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode styles */
|
||||||
|
.search-trigger {
|
||||||
|
background-color: rgba(248, 250, 252, 0.05) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
color: #555 !important;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #555 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-trigger {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
color: #aaa !important;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #aaa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { StaticCommandItem } from "./commands";
|
||||||
|
import type { HydratedIndexItem } from "../indexing/types";
|
||||||
|
|
||||||
|
export interface MatchIndices {
|
||||||
|
readonly 0: number;
|
||||||
|
readonly 1: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuseResultMatch {
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
indices: readonly MatchIndices[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombinedResult {
|
||||||
|
id: string;
|
||||||
|
type: "command" | "dynamic";
|
||||||
|
score: number;
|
||||||
|
item: StaticCommandItem | HydratedIndexItem;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuseResult<T> {
|
||||||
|
item: T;
|
||||||
|
refIndex: number;
|
||||||
|
score?: number;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { IndexItem } from "./types";
|
||||||
|
|
||||||
|
interface MessageMetadata {
|
||||||
|
messageId: number;
|
||||||
|
author: string;
|
||||||
|
senderId: number;
|
||||||
|
senderType: string;
|
||||||
|
timestamp: string;
|
||||||
|
hasAttachments: boolean;
|
||||||
|
attachmentCount: number;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssessmentMetadata {
|
||||||
|
assessmentId?: number;
|
||||||
|
messageId?: number;
|
||||||
|
subject?: string;
|
||||||
|
term?: string;
|
||||||
|
programmeId?: number;
|
||||||
|
metaclassId?: number;
|
||||||
|
timestamp: string;
|
||||||
|
isMessageBased?: boolean;
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
|
||||||
|
|
||||||
|
export const actionMap: Record<string, ActionHandler<any>> = {
|
||||||
|
message: ((item: IndexItem & { metadata: MessageMetadata }) => {
|
||||||
|
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
assessment: ((item: IndexItem & { metadata: AssessmentMetadata }) => {
|
||||||
|
if (item.metadata.isMessageBased) {
|
||||||
|
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
|
||||||
|
} else {
|
||||||
|
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
|
||||||
|
}
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
const DB_NAME = "betterseqta-index";
|
||||||
|
const META_STORE = "meta";
|
||||||
|
const VERSION_KEY = "betterseqta-index-version";
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
// Get the current version from localStorage or start at 1
|
||||||
|
function getCurrentVersion(): number {
|
||||||
|
const storedVersion = localStorage.getItem(VERSION_KEY);
|
||||||
|
return storedVersion ? parseInt(storedVersion, 10) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the version in localStorage
|
||||||
|
function updateVersion(version: number) {
|
||||||
|
localStorage.setItem(VERSION_KEY, version.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
let request: IDBOpenDBRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
request = indexedDB.open(DB_NAME, currentVersion);
|
||||||
|
} catch (e) {
|
||||||
|
// If there's a version error, try to delete the database and start fresh
|
||||||
|
console.warn("Database version conflict, recreating database...");
|
||||||
|
indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
request = indexedDB.open(DB_NAME, 1);
|
||||||
|
updateVersion(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
const existingStores = Array.from(db.objectStoreNames);
|
||||||
|
|
||||||
|
// Always ensure META_STORE exists
|
||||||
|
if (!existingStores.includes(META_STORE)) {
|
||||||
|
db.createObjectStore(META_STORE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update version in localStorage to match the database
|
||||||
|
updateVersion(event.newVersion || 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error("Error opening database:", request.error);
|
||||||
|
// If there's an error, try to recover by deleting and recreating
|
||||||
|
indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
|
||||||
|
const db = await openDB();
|
||||||
|
|
||||||
|
// Create store dynamically if needed
|
||||||
|
if (!db.objectStoreNames.contains(store)) {
|
||||||
|
db.close();
|
||||||
|
await upgradeDB(store);
|
||||||
|
return getStore(store, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(store, mode);
|
||||||
|
return tx.objectStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeDB(newStore: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
const newVersion = currentVersion + 1;
|
||||||
|
|
||||||
|
// Close any existing connections
|
||||||
|
if (dbPromise) {
|
||||||
|
dbPromise.then((db) => db.close());
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = indexedDB.open(DB_NAME, newVersion);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(newStore)) {
|
||||||
|
db.createObjectStore(newStore);
|
||||||
|
}
|
||||||
|
// Update version in localStorage
|
||||||
|
updateVersion(event.newVersion || newVersion);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
dbPromise = Promise.resolve(request.result);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error("Error upgrading database:", request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAll(store: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in getAll for store ${store}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(store: string, key: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.get(key);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in get for store ${store}, key ${key}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put(
|
||||||
|
store: string,
|
||||||
|
value: any,
|
||||||
|
key?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, "readwrite");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = key ? s.put(value, key) : s.put(value);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in put for store ${store}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(store: string, key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, "readwrite");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.delete(key);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in remove for store ${store}, key ${key}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clear(store: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, "readwrite");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.clear();
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in clear for store ${store}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to reset the database if needed
|
||||||
|
export async function resetDatabase(): Promise<void> {
|
||||||
|
if (dbPromise) {
|
||||||
|
const db = await dbPromise;
|
||||||
|
db.close();
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { clear, getAll, put, remove } from "./db";
|
||||||
|
import { jobs } from "./jobs";
|
||||||
|
import { renderComponentMap } from "./renderComponents";
|
||||||
|
import type { HydratedIndexItem, IndexItem, Job, JobContext } from "./types";
|
||||||
|
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||||
|
|
||||||
|
const META_STORE = "meta";
|
||||||
|
const LOCK_KEY = "bsq-indexer-lock";
|
||||||
|
const HEARTBEAT_INTERVAL = 10000;
|
||||||
|
const LOCK_TIMEOUT = 20000;
|
||||||
|
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function shouldRun(job: Job, lastRun?: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (job.frequency === "pageLoad") return true;
|
||||||
|
if (!lastRun) return true;
|
||||||
|
|
||||||
|
if (job.frequency.type === "interval") {
|
||||||
|
return now - lastRun >= job.frequency.ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.frequency.type === "expiry") {
|
||||||
|
return now - lastRun >= job.frequency.afterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||||
|
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIndex(): boolean {
|
||||||
|
const last = parseInt(localStorage.getItem(LOCK_KEY) || "0", 10);
|
||||||
|
return isNaN(last) || Date.now() - last > LOCK_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat() {
|
||||||
|
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||||
|
localStorage.removeItem(LOCK_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchProgress(completed: number, total: number, indexing: boolean, status?: string, detail?: string) {
|
||||||
|
const event = new CustomEvent("indexing-progress", {
|
||||||
|
detail: { completed, total, indexing, status, detail },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllStoredItems(): Promise<HydratedIndexItem[]> {
|
||||||
|
const all: HydratedIndexItem[] = [];
|
||||||
|
const jobIds = Object.keys(jobs);
|
||||||
|
|
||||||
|
for (const jobId of jobIds) {
|
||||||
|
try {
|
||||||
|
const items = await getAll(jobId) as IndexItem[];
|
||||||
|
const job = jobs[jobId];
|
||||||
|
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||||
|
|
||||||
|
if (!renderComponent) {
|
||||||
|
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// Ensure item has all required fields before pushing
|
||||||
|
if (item && item.id && item.text && item.category && item.actionId && job.renderComponentId) {
|
||||||
|
all.push({
|
||||||
|
...item,
|
||||||
|
renderComponent: renderComponent || undefined, // Assign undefined if not found
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`Skipping invalid item from job ${jobId}:`, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading items for job ${jobId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.debug(`[Indexer] Loaded ${all.length} items from non-vector storage.`);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runIndexing(): Promise<void> {
|
||||||
|
if (!shouldIndex()) {
|
||||||
|
console.debug(
|
||||||
|
"%c[Indexer] Skipping indexing (another tab has the lock)",
|
||||||
|
"color: gray",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startHeartbeat();
|
||||||
|
console.debug("%c[Indexer] Starting indexing...", "color: green");
|
||||||
|
|
||||||
|
const jobIds = Object.keys(jobs);
|
||||||
|
let completedJobs = 0;
|
||||||
|
// Add an extra step for vectorization
|
||||||
|
const totalSteps = jobIds.length + 1;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
|
||||||
|
|
||||||
|
const allItemsFromJobs: HydratedIndexItem[] = [];
|
||||||
|
|
||||||
|
// --- Step 1: Run Fetching/Storing Jobs (Main Thread) ---
|
||||||
|
for (const jobId of jobIds) {
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, `Running job: ${jobs[jobId].label}`);
|
||||||
|
const job = jobs[jobId];
|
||||||
|
const lastRun = await getLastRunMeta(jobId);
|
||||||
|
|
||||||
|
if (!shouldRun(job, lastRun)) {
|
||||||
|
console.debug(
|
||||||
|
`%c[Indexer] Skipping job "${jobId}" (not due)`,
|
||||||
|
"color: gray",
|
||||||
|
);
|
||||||
|
completedJobs++;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, `Skipped job: ${job.label}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These DB operations happen on the main thread (acceptable per request)
|
||||||
|
const getStoredItems = async () => await getAll(jobId);
|
||||||
|
const setStoredItems = async (items: IndexItem[]) => {
|
||||||
|
await clear(jobId);
|
||||||
|
// Add validation before putting
|
||||||
|
const validItems = items.filter(i => i && i.id);
|
||||||
|
if (validItems.length !== items.length) {
|
||||||
|
console.warn(`[Indexer Job ${jobId}] Filtered out ${items.length - validItems.length} invalid items before storing.`);
|
||||||
|
}
|
||||||
|
await Promise.all(validItems.map((i) => put(jobId, i, i.id)));
|
||||||
|
};
|
||||||
|
const addItem = async (item: IndexItem) => {
|
||||||
|
if (item && item.id) { // Add validation
|
||||||
|
await put(jobId, item, item.id);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Indexer Job ${jobId}] Attempted to add invalid item:`, item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeItem = async (id: string) => {
|
||||||
|
await remove(jobId, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx: JobContext = {
|
||||||
|
getStoredItems,
|
||||||
|
setStoredItems,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newItemsRaw = await job.run(ctx);
|
||||||
|
const stored = await getStoredItems();
|
||||||
|
|
||||||
|
let merged = mergeItems(stored, newItemsRaw);
|
||||||
|
if (job.purge) merged = job.purge(merged);
|
||||||
|
|
||||||
|
await setStoredItems(merged); // Store merged non-vector data
|
||||||
|
await updateLastRunMeta(jobId);
|
||||||
|
|
||||||
|
// Hydrate items for vector processing
|
||||||
|
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||||
|
if (!renderComponent) {
|
||||||
|
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId}) during hydration`);
|
||||||
|
}
|
||||||
|
const hydratedItems = merged
|
||||||
|
.filter(item => item && item.id && item.text && item.category && item.actionId && job.renderComponentId) // Filter invalid before hydrating
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
renderComponent: renderComponent || undefined, // Assign undefined if not found
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (hydratedItems.length !== merged.length) {
|
||||||
|
console.warn(`[Indexer Job ${jobId}] Filtered out ${merged.length - hydratedItems.length} invalid items during hydration.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
allItemsFromJobs.push(...hydratedItems);
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`%c[Indexer] ✅ ${job.label}: ${newItemsRaw.length} new items fetched, ${merged.length} total stored (non-vector).`,
|
||||||
|
"color: #00c46f",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.debug(`%c[Indexer] ❌ ${job.label} failed:`, "color: red");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
completedJobs++;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, `Finished job: ${job.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: Delegate Vectorization to Worker (Off Main Thread) ---
|
||||||
|
if (allItemsFromJobs.length > 0) {
|
||||||
|
console.debug(
|
||||||
|
`%c[Indexer] Sending ${allItemsFromJobs.length} items to worker for vectorization...`,
|
||||||
|
"color: #4ea1ff",
|
||||||
|
);
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workerManager = VectorWorkerManager.getInstance();
|
||||||
|
// Pass a progress callback to the worker manager
|
||||||
|
await workerManager.processItems(allItemsFromJobs, (progress) => {
|
||||||
|
// Update overall progress based on worker feedback
|
||||||
|
let detailMessage = progress.message || '';
|
||||||
|
if (progress.status === 'processing' && progress.total && progress.processed !== undefined) {
|
||||||
|
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
||||||
|
// You could potentially update the 'completed' count more granularly here
|
||||||
|
// For simplicity, we'll just update the detail message
|
||||||
|
} else if (progress.status === 'complete') {
|
||||||
|
detailMessage = "Vectorization complete";
|
||||||
|
// Mark the vectorization step as complete
|
||||||
|
dispatchProgress(totalSteps, totalSteps, true, "Vectorization finished");
|
||||||
|
} else if (progress.status === 'error') {
|
||||||
|
detailMessage = `Vectorization error: ${progress.message}`;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Vectorization failed", detailMessage); // Show error
|
||||||
|
} else if (progress.status === 'started') {
|
||||||
|
detailMessage = `Vectorization started for ${progress.total} items`;
|
||||||
|
} else if (progress.status === 'cancelled') {
|
||||||
|
detailMessage = `Vectorization cancelled: ${progress.message}`;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Vectorization cancelled", detailMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the status detail
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Vectorization in progress", detailMessage);
|
||||||
|
|
||||||
|
// When worker signals completion of *its* task, mark the final step complete
|
||||||
|
if (progress.status === 'complete') {
|
||||||
|
completedJobs++; // Increment completion count *after* vectorization finishes
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished"); // Set indexing to false
|
||||||
|
} else if (progress.status === 'error' || progress.status === 'cancelled') {
|
||||||
|
// Don't increment completed count on failure/cancel, just stop indexing indicator
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Indexing stopped due to error/cancel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.debug("%c[Indexer] Vectorization task sent to worker.", "color: green");
|
||||||
|
// Note: runIndexing might return *before* vectorization is complete now.
|
||||||
|
// The progress updates will signal the true end state.
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`%c[Indexer] ❌ Failed to send items to vector worker:`, "color: red", error);
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Vectorization failed", String(error)); // Stop indexing indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.debug("%c[Indexer] No items to send for vectorization.", "color: gray");
|
||||||
|
// If no vectorization needed, indexing is done here.
|
||||||
|
completedJobs++; // Count the "skipped" vectorization step
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished (no vectorization needed)");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Stop heartbeat ONLY when all jobs *and* the vectorization dispatch are done.
|
||||||
|
// The actual *completion* of vectorization is now asynchronous.
|
||||||
|
stopHeartbeat();
|
||||||
|
// Final progress update might be handled by the worker callback now.
|
||||||
|
// dispatchProgress(completedJobs, totalSteps, false); // This might be premature
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
|
||||||
|
const map = new Map<string, IndexItem>();
|
||||||
|
// Prioritize incoming items if IDs clash
|
||||||
|
for (const item of existing) {
|
||||||
|
if (item && item.id) map.set(item.id, item);
|
||||||
|
}
|
||||||
|
for (const item of incoming) {
|
||||||
|
if (item && item.id) map.set(item.id, item);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type { Job } from "./types";
|
||||||
|
import type { IndexItem } from "./types";
|
||||||
|
|
||||||
|
interface MessageNotification {
|
||||||
|
notificationID: number;
|
||||||
|
type: "message";
|
||||||
|
message: {
|
||||||
|
subtitle: string;
|
||||||
|
messageID: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssessmentNotification {
|
||||||
|
notificationID: number;
|
||||||
|
type: "coneqtassessments";
|
||||||
|
coneqtAssessments: {
|
||||||
|
programmeID: number;
|
||||||
|
metaclassID: number;
|
||||||
|
subtitle: string;
|
||||||
|
term: string;
|
||||||
|
title: string;
|
||||||
|
assessmentID: number;
|
||||||
|
subjectCode: string;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notification = MessageNotification | AssessmentNotification;
|
||||||
|
|
||||||
|
interface MessageListResponse {
|
||||||
|
payload: {
|
||||||
|
hasMore: boolean;
|
||||||
|
messages: {
|
||||||
|
date: string;
|
||||||
|
attachments: boolean;
|
||||||
|
attachmentCount: number;
|
||||||
|
read: number;
|
||||||
|
sender: string;
|
||||||
|
sender_id: number;
|
||||||
|
sender_type: string;
|
||||||
|
subject: string;
|
||||||
|
id: number;
|
||||||
|
participants: Array<{
|
||||||
|
name: string;
|
||||||
|
photo: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}[];
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageContentResponse {
|
||||||
|
payload: {
|
||||||
|
date: string;
|
||||||
|
blind: boolean;
|
||||||
|
read: boolean;
|
||||||
|
subject: string;
|
||||||
|
sender_type: string;
|
||||||
|
sender_id: number;
|
||||||
|
starred: boolean;
|
||||||
|
contents: string;
|
||||||
|
sender: string;
|
||||||
|
files: any[];
|
||||||
|
id: number;
|
||||||
|
participants: Array<{
|
||||||
|
read: number;
|
||||||
|
name: string;
|
||||||
|
photo: string;
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to strip HTML tags from text
|
||||||
|
function stripHtmlTags(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch messages with pagination
|
||||||
|
async function fetchMessages(
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<MessageListResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${location.origin}/seqta/student/load/message`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
searchValue: "",
|
||||||
|
sortBy: "date",
|
||||||
|
sortOrder: "desc",
|
||||||
|
action: "list",
|
||||||
|
label: "inbox",
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
datetimeUntil: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch message content
|
||||||
|
async function fetchMessageContent(
|
||||||
|
messageId: number,
|
||||||
|
): Promise<MessageContentResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${location.origin}/seqta/student/load/message`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "message",
|
||||||
|
id: messageId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch notifications
|
||||||
|
async function fetchNotifications(): Promise<Notification[]> {
|
||||||
|
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
timestamp: "1970-01-01 00:00:00.0",
|
||||||
|
hash: "#?page=/notifications",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.notifications ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jobs: Record<string, Job> = {
|
||||||
|
messages: {
|
||||||
|
id: "messages",
|
||||||
|
label: "Messages",
|
||||||
|
renderComponentId: "message",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 5 }, // every 5 minutes
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
// Get existing items first
|
||||||
|
const existing = await ctx.getStoredItems();
|
||||||
|
const existingIds = new Set(existing.map((i) => i.id));
|
||||||
|
const newItems: IndexItem[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 100;
|
||||||
|
let hasMore = true;
|
||||||
|
let consecutiveExisting = 0;
|
||||||
|
|
||||||
|
// Fetch all messages with pagination
|
||||||
|
while (hasMore) {
|
||||||
|
try {
|
||||||
|
const response = await fetchMessages(offset, limit);
|
||||||
|
|
||||||
|
if (response.status !== "200") {
|
||||||
|
console.error("Failed to fetch messages:", response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = response.payload.messages;
|
||||||
|
hasMore = response.payload.hasMore;
|
||||||
|
|
||||||
|
// Process each message
|
||||||
|
for (const message of messages) {
|
||||||
|
const id = message.id.toString();
|
||||||
|
|
||||||
|
// Skip if we already have this message
|
||||||
|
if (existingIds.has(id)) {
|
||||||
|
consecutiveExisting++;
|
||||||
|
// If we've found 20 consecutive existing messages, assume we've caught up
|
||||||
|
if (consecutiveExisting >= 20) {
|
||||||
|
console.debug(
|
||||||
|
"[Messages Job] Found 20 consecutive existing messages, stopping fetch",
|
||||||
|
);
|
||||||
|
hasMore = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset consecutive counter when we find a new message
|
||||||
|
consecutiveExisting = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch message content
|
||||||
|
const contentResponse = await fetchMessageContent(message.id);
|
||||||
|
|
||||||
|
if (contentResponse.status !== "200") {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch message content:",
|
||||||
|
contentResponse,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = stripHtmlTags(contentResponse.payload.contents);
|
||||||
|
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: message.subject,
|
||||||
|
category: "messages",
|
||||||
|
content: `From: ${message.sender}\n\n${content}`,
|
||||||
|
dateAdded: new Date(message.date).getTime(),
|
||||||
|
metadata: {
|
||||||
|
messageId: message.id,
|
||||||
|
author: message.sender,
|
||||||
|
senderId: message.sender_id,
|
||||||
|
senderType: message.sender_type,
|
||||||
|
timestamp: message.date,
|
||||||
|
hasAttachments: message.attachments,
|
||||||
|
attachmentCount: message.attachmentCount,
|
||||||
|
read: message.read === 1,
|
||||||
|
},
|
||||||
|
actionId: "message",
|
||||||
|
renderComponentId: "message",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to existingIds as we process to prevent duplicates in the same run
|
||||||
|
existingIds.add(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching message content:", error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching messages:", error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to avoid overwhelming the server
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Messages Job] Found ${newItems.length} new messages`);
|
||||||
|
return newItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep messages from the last 30 days
|
||||||
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
return items.filter((i) => i.dateAdded >= cutoff);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
assessments: {
|
||||||
|
id: "assessments",
|
||||||
|
label: "Assessments",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 15 }, // every 15 minutes
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
const notifications = await fetchNotifications();
|
||||||
|
const assessmentNotifications = notifications.filter(
|
||||||
|
(n): n is MessageNotification | AssessmentNotification =>
|
||||||
|
n.type === "coneqtassessments" ||
|
||||||
|
(n.type === "message" &&
|
||||||
|
n.message.title.toLowerCase().includes("assessment")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = await ctx.getStoredItems();
|
||||||
|
const existingIds = new Set(existing.map((i) => i.id));
|
||||||
|
const newItems: IndexItem[] = [];
|
||||||
|
|
||||||
|
for (const notification of assessmentNotifications) {
|
||||||
|
const id = notification.notificationID.toString();
|
||||||
|
if (existingIds.has(id)) continue;
|
||||||
|
|
||||||
|
if (notification.type === "coneqtassessments") {
|
||||||
|
const { coneqtAssessments: assessment } = notification;
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: assessment.title,
|
||||||
|
category: "assessments",
|
||||||
|
content: assessment.subtitle,
|
||||||
|
dateAdded: new Date(notification.timestamp).getTime(),
|
||||||
|
metadata: {
|
||||||
|
assessmentId: assessment.assessmentID,
|
||||||
|
subject: assessment.subjectCode,
|
||||||
|
term: assessment.term,
|
||||||
|
programmeId: assessment.programmeID,
|
||||||
|
metaclassId: assessment.metaclassID,
|
||||||
|
timestamp: notification.timestamp,
|
||||||
|
},
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle message-based assessments
|
||||||
|
const { message } = notification;
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: message.title,
|
||||||
|
category: "assessments",
|
||||||
|
content: `From: ${message.subtitle}`,
|
||||||
|
dateAdded: new Date(notification.timestamp).getTime(),
|
||||||
|
metadata: {
|
||||||
|
messageId: message.messageID,
|
||||||
|
author: message.subtitle,
|
||||||
|
timestamp: notification.timestamp,
|
||||||
|
isMessageBased: true,
|
||||||
|
},
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep assessments from the current year
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(0); // January
|
||||||
|
date.setDate(1);
|
||||||
|
date.setHours(0);
|
||||||
|
date.setMinutes(0);
|
||||||
|
date.setSeconds(0);
|
||||||
|
const cutoff = date.getTime();
|
||||||
|
return items.filter((i) => i.dateAdded >= cutoff);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// We can add more job types here as needed:
|
||||||
|
// - notices
|
||||||
|
// - timetable changes
|
||||||
|
// - homework
|
||||||
|
// etc.
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
import AssessmentComponent from "../components/AssessmentItem.svelte";
|
||||||
|
// import other components as needed
|
||||||
|
|
||||||
|
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||||
|
assessment: AssessmentComponent as unknown as typeof SvelteComponent,
|
||||||
|
// messages: MessageComponent,
|
||||||
|
// subject: SubjectComponent,
|
||||||
|
// etc...
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
|
||||||
|
export interface IndexItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
dateAdded: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
actionId: string;
|
||||||
|
renderComponentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydratedIndexItem extends IndexItem {
|
||||||
|
renderComponent: typeof SvelteComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Frequency =
|
||||||
|
| "pageLoad"
|
||||||
|
| { type: "interval"; ms: number }
|
||||||
|
| { type: "expiry"; afterMs: number };
|
||||||
|
|
||||||
|
export interface JobContext {
|
||||||
|
getStoredItems: () => Promise<IndexItem[]>;
|
||||||
|
setStoredItems: (items: IndexItem[]) => Promise<void>;
|
||||||
|
addItem: (item: IndexItem) => Promise<void>;
|
||||||
|
removeItem: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
frequency: Frequency;
|
||||||
|
renderComponentId: string;
|
||||||
|
run: (ctx: JobContext) => Promise<IndexItem[]>;
|
||||||
|
purge?: (items: IndexItem[]) => IndexItem[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import {
|
||||||
|
EmbeddingIndex,
|
||||||
|
getEmbedding,
|
||||||
|
initializeModel,
|
||||||
|
} from "client-vector-search";
|
||||||
|
import type { HydratedIndexItem } from "../types";
|
||||||
|
|
||||||
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
|
let isInitialized = false;
|
||||||
|
let currentAbortController: AbortController | null = null;
|
||||||
|
|
||||||
|
async function initWorker() {
|
||||||
|
if (isInitialized) {
|
||||||
|
console.debug("Vector worker already initialized.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug("Initializing vector worker...");
|
||||||
|
try {
|
||||||
|
await initializeModel();
|
||||||
|
vectorIndex = new EmbeddingIndex([]);
|
||||||
|
|
||||||
|
const stored = await vectorIndex.getAllObjectsFromIndexedDB();
|
||||||
|
if (stored.length > 0) {
|
||||||
|
stored.forEach((item) => vectorIndex!.add(item));
|
||||||
|
console.debug(
|
||||||
|
`Vector index loaded ${stored.length} items from IndexedDB.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.debug("No existing vector index found in IndexedDB.");
|
||||||
|
}
|
||||||
|
isInitialized = true;
|
||||||
|
console.debug("Vector worker initialized successfully.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to initialize vector worker:", e);
|
||||||
|
// Set as initialized even on error to prevent retries, but index will be null
|
||||||
|
isInitialized = true;
|
||||||
|
vectorIndex = null; // Ensure index is null on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vectorizeItem(
|
||||||
|
item: HydratedIndexItem,
|
||||||
|
): Promise<(HydratedIndexItem & { embedding: number[] }) | null> {
|
||||||
|
// Simplified for brevity - assumes embedding function doesn't need cancellation signal
|
||||||
|
try {
|
||||||
|
const textToEmbed = [
|
||||||
|
item.text,
|
||||||
|
item.content,
|
||||||
|
item.category,
|
||||||
|
item.metadata?.author,
|
||||||
|
item.metadata?.subject,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const embedding = await getEmbedding(textToEmbed);
|
||||||
|
return { ...item, embedding };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error vectorizing item ${item.id}:`, error);
|
||||||
|
return null; // Return null if vectorization fails for an item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
|
||||||
|
console.debug("Worker received process request.");
|
||||||
|
if (!vectorIndex) {
|
||||||
|
console.warn(
|
||||||
|
"Processing requested but vector index not ready. Attempting init.",
|
||||||
|
);
|
||||||
|
await initWorker(); // Attempt initialization if not ready
|
||||||
|
if (!vectorIndex) {
|
||||||
|
// Check again after attempt
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "error",
|
||||||
|
message:
|
||||||
|
"Vector index not available for processing after init attempt.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find items we haven't processed yet by checking against the index instance
|
||||||
|
const unprocessedItems = items.filter((item) => {
|
||||||
|
if (signal.aborted) return false; // Check cancellation during filtering
|
||||||
|
try {
|
||||||
|
// Check if the item ID already exists in the index (loaded or added)
|
||||||
|
return !vectorIndex!.get({ id: item.id });
|
||||||
|
} catch (e) {
|
||||||
|
// If get throws (e.g., item not found), it means it's unprocessed
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled before starting.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled before start",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unprocessedItems.length === 0) {
|
||||||
|
console.debug("No new items to process.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "complete", message: "No new items to process" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`Starting processing of ${unprocessedItems.length} items.`);
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "started",
|
||||||
|
total: unprocessedItems.length,
|
||||||
|
processed: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const BATCH_SIZE = 5;
|
||||||
|
let processedCount = 0;
|
||||||
|
for (let i = 0; i < unprocessedItems.length; i += BATCH_SIZE) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled during batching.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled during batching",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = unprocessedItems.slice(i, i + BATCH_SIZE);
|
||||||
|
// Vectorize batch
|
||||||
|
const vectorizationResults = await Promise.all(batch.map(vectorizeItem));
|
||||||
|
const successfullyVectorized = vectorizationResults.filter(
|
||||||
|
(result) => result !== null,
|
||||||
|
) as (HydratedIndexItem & { embedding: number[] })[];
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled after vectorization batch.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled after vectorization",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add successfully vectorized items to index
|
||||||
|
if (successfullyVectorized.length > 0) {
|
||||||
|
try {
|
||||||
|
successfullyVectorized.forEach((item) => vectorIndex!.add(item));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error adding batch to index:", e);
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "error", message: `Error adding to index: ${e}` },
|
||||||
|
});
|
||||||
|
// Decide whether to continue or stop on error
|
||||||
|
// return; // Example: Stop processing if adding fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled before saving batch.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled before saving",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save index after processing the batch
|
||||||
|
try {
|
||||||
|
await vectorIndex!.saveIndex("indexedDB");
|
||||||
|
console.debug(`Saved index after processing batch ${i / BATCH_SIZE + 1}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error saving index batch:", e);
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "error", message: `Error saving index batch: ${e}` },
|
||||||
|
});
|
||||||
|
// Continue processing next batch even if saving failed? Or stop?
|
||||||
|
// return; // Example: Stop if saving fails
|
||||||
|
}
|
||||||
|
|
||||||
|
processedCount = Math.min(i + BATCH_SIZE, unprocessedItems.length);
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "processing",
|
||||||
|
total: unprocessedItems.length,
|
||||||
|
processed: processedCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Yield control briefly to allow other messages (like cancellation) to be processed
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signal.aborted) {
|
||||||
|
console.debug("Processing completed successfully.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "complete", message: "All items processed successfully" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug("Processing completed, but was cancelled.");
|
||||||
|
// No need to send 'cancelled' again if already sent during batching
|
||||||
|
// self.postMessage({ type: 'progress', data: { status: 'cancelled', message: 'Processing finished but was cancelled' }});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(
|
||||||
|
query: string,
|
||||||
|
topK: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
messageId: string,
|
||||||
|
) {
|
||||||
|
console.debug(
|
||||||
|
`Worker received search request (ID: ${messageId}): "${query}"`,
|
||||||
|
);
|
||||||
|
if (!vectorIndex) {
|
||||||
|
console.warn(
|
||||||
|
`Search (ID: ${messageId}) requested but vector index not ready. Attempting init.`,
|
||||||
|
);
|
||||||
|
await initWorker(); // Attempt initialization
|
||||||
|
// Re-check after waiting/init attempt
|
||||||
|
if (!vectorIndex) {
|
||||||
|
console.error(
|
||||||
|
`Search (ID: ${messageId}) failed: Vector index unavailable after init attempt.`,
|
||||||
|
);
|
||||||
|
self.postMessage({
|
||||||
|
type: "searchError",
|
||||||
|
data: { messageId, error: "Vector index not available." },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug(
|
||||||
|
`Vector index ready after init for search (ID: ${messageId}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug(`Search (ID: ${messageId}) cancelled before starting.`);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.debug(`Getting embedding for query (ID: ${messageId})...`);
|
||||||
|
const queryEmbedding = await getEmbedding(query);
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug(`Search (ID: ${messageId}) cancelled after embedding.`);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`Performing vector search (ID: ${messageId})...`);
|
||||||
|
// Await the search and let TypeScript infer the type
|
||||||
|
const results = await vectorIndex!.search(queryEmbedding, {
|
||||||
|
topK,
|
||||||
|
useStorage: "indexedDB", // Ensure we search the stored index
|
||||||
|
});
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`Vector search (ID: ${messageId}) completed with ${results.length} results.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug(
|
||||||
|
`Search (ID: ${messageId}) cancelled after search completed, discarding results.`,
|
||||||
|
);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post results back to the main thread
|
||||||
|
self.postMessage({ type: "searchResults", data: { messageId, results } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Vector search error in worker (ID: ${messageId}):`, error);
|
||||||
|
// Ensure signal isn't checked *after* an error occurred before posting error message
|
||||||
|
if (!signal.aborted) {
|
||||||
|
// Only post error if not cancelled
|
||||||
|
self.postMessage({
|
||||||
|
type: "searchError",
|
||||||
|
data: {
|
||||||
|
messageId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
`Search (ID: ${messageId}) encountered error but was cancelled, suppressing error message.`,
|
||||||
|
);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } }); // Still notify of cancellation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages from the main thread
|
||||||
|
self.addEventListener("message", async (e) => {
|
||||||
|
// Make sure data and type exist
|
||||||
|
if (!e.data || !e.data.type) {
|
||||||
|
console.warn("Worker received message with no data or type.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, data, messageId } = e.data; // messageId used for requests needing response/cancellation tracking
|
||||||
|
|
||||||
|
// Cancel previous long-running operation (process or search) if a new one starts
|
||||||
|
if (type === "process" || type === "search") {
|
||||||
|
if (currentAbortController) {
|
||||||
|
console.debug(
|
||||||
|
`Worker cancelling previous operation due to new '${type}' request.`,
|
||||||
|
);
|
||||||
|
currentAbortController.abort(`New '${type}' operation requested`);
|
||||||
|
}
|
||||||
|
currentAbortController = new AbortController();
|
||||||
|
console.debug(`Worker starting new '${type}' operation.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the signal from the *current* controller for the task being started
|
||||||
|
const signal = currentAbortController?.signal;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "process":
|
||||||
|
if (signal && data?.items) {
|
||||||
|
await processItems(data.items, signal);
|
||||||
|
} else if (!signal) {
|
||||||
|
console.error(
|
||||||
|
"Process message received but no abort signal available.",
|
||||||
|
);
|
||||||
|
} else if (!data?.items) {
|
||||||
|
console.error("Process message received without 'items' data.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "error",
|
||||||
|
message: "Process command received without items.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "search":
|
||||||
|
if (signal && messageId && typeof data?.query === "string") {
|
||||||
|
await search(data.query, data.topK ?? 10, signal, messageId);
|
||||||
|
} else {
|
||||||
|
const errorReason = !signal
|
||||||
|
? "Missing signal"
|
||||||
|
: !messageId
|
||||||
|
? "Missing messageId"
|
||||||
|
: "Missing or invalid query";
|
||||||
|
console.error(`Search message received invalid: ${errorReason}.`, {
|
||||||
|
data,
|
||||||
|
messageId,
|
||||||
|
signalExists: !!signal,
|
||||||
|
});
|
||||||
|
// Send an error back if messageId exists
|
||||||
|
if (messageId) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "searchError",
|
||||||
|
data: { messageId, error: `Worker internal error: ${errorReason}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "init":
|
||||||
|
// Init should not be cancellable in the same way, it's foundational
|
||||||
|
// Check if already initialized before potentially running it again
|
||||||
|
if (!isInitialized) {
|
||||||
|
await initWorker();
|
||||||
|
self.postMessage({ type: "ready" }); // Signal ready *after* init attempt
|
||||||
|
} else {
|
||||||
|
console.debug("Received init message, but worker already initialized.");
|
||||||
|
self.postMessage({ type: "ready" }); // Signal ready anyway
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// No explicit 'cancel' case needed as new tasks auto-cancel previous ones
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn("Unknown message type received by vector worker:", type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial check or trigger for initialization when the worker starts
|
||||||
|
initWorker()
|
||||||
|
.then(() => {
|
||||||
|
self.postMessage({ type: "ready" });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Initial worker initialization failed:", err);
|
||||||
|
// Still need to signal readiness, perhaps with an error state?
|
||||||
|
// Or rely on the first 'process' or 'search' to retry init.
|
||||||
|
// For now, just signal ready, but the index might be null.
|
||||||
|
self.postMessage({ type: "ready" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type { HydratedIndexItem } from '../types';
|
||||||
|
import vectorWorker from './vectorWorker.ts?inlineWorker';
|
||||||
|
import type { SearchResult } from 'client-vector-search';
|
||||||
|
|
||||||
|
export type ProgressCallback = (data: {
|
||||||
|
status: 'started' | 'processing' | 'complete' | 'error' | 'cancelled';
|
||||||
|
total?: number;
|
||||||
|
processed?: number;
|
||||||
|
message?: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export class VectorWorkerManager {
|
||||||
|
private static instance: VectorWorkerManager;
|
||||||
|
private worker: Worker | null = null;
|
||||||
|
private isInitialized = false;
|
||||||
|
private readyPromise: Promise<void> | null = null; // To await initialization
|
||||||
|
private progressCallback: ProgressCallback | null = null;
|
||||||
|
private searchPromises = new Map<string, { resolve: (value: SearchResult[]) => void, reject: (reason?: any) => void, timer: NodeJS.Timeout }>();
|
||||||
|
private debounceTimer: NodeJS.Timeout | null = null;
|
||||||
|
private lastSearchParams: { query: string; topK: number; resolve: (results: SearchResult[]) => void, reject: (reason?: any) => void } | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// Start initialization immediately, but allow awaiting it
|
||||||
|
this.readyPromise = this.initWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): VectorWorkerManager {
|
||||||
|
if (!VectorWorkerManager.instance) {
|
||||||
|
VectorWorkerManager.instance = new VectorWorkerManager();
|
||||||
|
}
|
||||||
|
return VectorWorkerManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initWorker(): Promise<void> {
|
||||||
|
// If already initialized or initializing, return the existing promise
|
||||||
|
if (this.isInitialized) return Promise.resolve();
|
||||||
|
if (this.readyPromise) return this.readyPromise;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
// Create the worker
|
||||||
|
this.worker = vectorWorker();
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.error('Vector worker initialization timed out');
|
||||||
|
this.worker?.terminate(); // Clean up worker if it exists
|
||||||
|
this.worker = null;
|
||||||
|
this.isInitialized = false; // Ensure state reflects failure
|
||||||
|
this.readyPromise = null; // Allow retrying init later
|
||||||
|
reject(new Error('Worker initialization timed out'));
|
||||||
|
}, 10000); // Increased timeout
|
||||||
|
|
||||||
|
// Set up message handling
|
||||||
|
this.worker!.addEventListener('message', (e) => {
|
||||||
|
const { type, data } = e.data;
|
||||||
|
console.debug("Message from vector worker:", type, data);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'ready':
|
||||||
|
this.isInitialized = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.debug('Vector worker initialized and ready.');
|
||||||
|
resolve(); // Resolve the init promise
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'progress':
|
||||||
|
if (this.progressCallback) {
|
||||||
|
this.progressCallback(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'searchResults':
|
||||||
|
const searchInfo = this.searchPromises.get(data.messageId);
|
||||||
|
if (searchInfo) {
|
||||||
|
clearTimeout(searchInfo.timer); // Clear timeout on success
|
||||||
|
searchInfo.resolve(data.results);
|
||||||
|
this.searchPromises.delete(data.messageId);
|
||||||
|
} else {
|
||||||
|
console.warn('Received search results for unknown messageId:', data.messageId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'searchError':
|
||||||
|
const errorInfo = this.searchPromises.get(data.messageId);
|
||||||
|
if (errorInfo) {
|
||||||
|
clearTimeout(errorInfo.timer); // Clear timeout on error
|
||||||
|
errorInfo.reject(new Error(data.error));
|
||||||
|
this.searchPromises.delete(data.messageId);
|
||||||
|
} else {
|
||||||
|
console.warn('Received search error for unknown messageId:', data.messageId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'searchCancelled':
|
||||||
|
const cancelledInfo = this.searchPromises.get(data.messageId);
|
||||||
|
if (cancelledInfo) {
|
||||||
|
clearTimeout(cancelledInfo.timer); // Clear timeout on cancel
|
||||||
|
// Reject with a specific cancellation error or resolve with empty? Let's reject.
|
||||||
|
cancelledInfo.reject(new Error('Search cancelled by worker'));
|
||||||
|
this.searchPromises.delete(data.messageId);
|
||||||
|
} else {
|
||||||
|
console.debug('Received cancellation for unknown messageId:', data.messageId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('Unknown message from worker:', type, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the worker
|
||||||
|
this.worker!.postMessage({ type: 'init' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensures worker is ready before proceeding
|
||||||
|
private async ensureReady() {
|
||||||
|
if (!this.readyPromise) {
|
||||||
|
// If init wasn't called or failed, try again
|
||||||
|
console.warn("Worker not initialized, attempting init...");
|
||||||
|
this.readyPromise = this.initWorker();
|
||||||
|
}
|
||||||
|
await this.readyPromise;
|
||||||
|
if (!this.isInitialized || !this.worker) {
|
||||||
|
throw new Error("Vector Worker is not available after initialization attempt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processItems(items: HydratedIndexItem[], onProgress?: ProgressCallback) {
|
||||||
|
await this.ensureReady(); // Wait for worker to be ready
|
||||||
|
|
||||||
|
this.progressCallback = onProgress || null;
|
||||||
|
|
||||||
|
// Cancel any ongoing search when starting processing
|
||||||
|
this.cancelAllSearches("Processing started");
|
||||||
|
|
||||||
|
console.debug(`Sending ${items.length} items to worker for processing.`);
|
||||||
|
this.worker!.postMessage({
|
||||||
|
type: 'process',
|
||||||
|
data: { items }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public search method
|
||||||
|
public async search(query: string, topK: number = 10): Promise<SearchResult[]> {
|
||||||
|
await this.ensureReady();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.lastSearchParams = { query, topK, resolve, reject };
|
||||||
|
|
||||||
|
const messageId = crypto.randomUUID();
|
||||||
|
if (this.lastSearchParams && this.worker) {
|
||||||
|
const currentParams = this.lastSearchParams; // Capture current params
|
||||||
|
this.lastSearchParams = null; // Clear last params *before* posting
|
||||||
|
this.debounceTimer = null;
|
||||||
|
|
||||||
|
// Set a timeout for the search operation itself
|
||||||
|
const searchTimeout = 10000; // e.g., 10 seconds
|
||||||
|
const searchTimer = setTimeout(() => {
|
||||||
|
if (this.searchPromises.has(messageId)) {
|
||||||
|
console.error(`Search timed out for messageId: ${messageId}`);
|
||||||
|
currentParams.reject(new Error(`Search timed out after ${searchTimeout}ms`));
|
||||||
|
this.searchPromises.delete(messageId);
|
||||||
|
}
|
||||||
|
}, searchTimeout);
|
||||||
|
|
||||||
|
|
||||||
|
this.searchPromises.set(messageId, { resolve: currentParams.resolve, reject: currentParams.reject, timer: searchTimer });
|
||||||
|
|
||||||
|
console.debug(`Sending search request (ID: ${messageId}) to worker: "${currentParams.query}"`);
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "search",
|
||||||
|
data: { query: currentParams.query, topK: currentParams.topK },
|
||||||
|
messageId
|
||||||
|
});
|
||||||
|
} else if (this.lastSearchParams) {
|
||||||
|
// This case might happen if ensureReady failed but didn't throw
|
||||||
|
console.error("Worker unavailable when trying to send search request.");
|
||||||
|
this.lastSearchParams.reject(new Error("Worker unavailable for search"));
|
||||||
|
this.lastSearchParams = null;
|
||||||
|
this.debounceTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to cancel all pending/debounced searches
|
||||||
|
private cancelAllSearches(reason: string = "Cancelled") {
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = null;
|
||||||
|
if (this.lastSearchParams) {
|
||||||
|
this.lastSearchParams.reject(new Error(`Search cancelled: ${reason}`));
|
||||||
|
this.lastSearchParams = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We might also want to tell the worker to cancel its *current* search
|
||||||
|
// if it supports it, but this requires worker modification.
|
||||||
|
// For now, just reject pending promises in the manager.
|
||||||
|
for (const [messageId, promiseInfo] of this.searchPromises.entries()) {
|
||||||
|
clearTimeout(promiseInfo.timer);
|
||||||
|
promiseInfo.reject(new Error(`Search cancelled: ${reason}`));
|
||||||
|
this.searchPromises.delete(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
console.debug("Terminating Vector Worker Manager...");
|
||||||
|
this.cancelAllSearches("Worker terminated"); // Cancel pending searches
|
||||||
|
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.terminate();
|
||||||
|
this.worker = null;
|
||||||
|
}
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.readyPromise = null; // Reset init promise
|
||||||
|
this.progressCallback = null;
|
||||||
|
// Clear the static instance? Or assume app lifecycle handles this?
|
||||||
|
// VectorWorkerManager.instance = null; // Uncomment if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import Fuse, { type FuseResult } from "fuse.js";
|
||||||
|
import { getStaticCommands, type StaticCommandItem } from "../core/commands";
|
||||||
|
import { getDynamicItems } from "../utils/dynamicItems";
|
||||||
|
import type { CombinedResult } from "../core/types";
|
||||||
|
import type { HydratedIndexItem } from "../indexing/types";
|
||||||
|
import { searchVectors } from "./vector/vectorSearch";
|
||||||
|
import type { VectorSearchResult } from "./vector/vectorTypes";
|
||||||
|
|
||||||
|
export function createSearchIndexes() {
|
||||||
|
const commands = getStaticCommands();
|
||||||
|
const dynamicItems = getDynamicItems();
|
||||||
|
|
||||||
|
const commandOptions = {
|
||||||
|
keys: ["text", "category", "keywords"],
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
minMatchCharLength: 1,
|
||||||
|
ignoreLocation: true,
|
||||||
|
useExtendedSearch: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dynamicOptions = {
|
||||||
|
keys: [
|
||||||
|
"text",
|
||||||
|
"content",
|
||||||
|
"category",
|
||||||
|
"metadata.author",
|
||||||
|
"metadata.subject",
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
minMatchCharLength: 3,
|
||||||
|
distance: 50,
|
||||||
|
useExtendedSearch: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||||
|
dynamicContentFuse: new Fuse(
|
||||||
|
dynamicItems,
|
||||||
|
dynamicOptions,
|
||||||
|
) as Fuse<HydratedIndexItem>,
|
||||||
|
commands,
|
||||||
|
dynamicItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchCommands(
|
||||||
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
|
query: string,
|
||||||
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
|
limit = 10,
|
||||||
|
): CombinedResult[] {
|
||||||
|
if (!commandsFuse) return [];
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return Array.from(commandIdToItemMap.values())
|
||||||
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // Sort by priority when no query
|
||||||
|
.slice(0, limit) // Limit results even when no query
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: "command" as const,
|
||||||
|
score: 100 + (item.priority ?? 0),
|
||||||
|
item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = commandsFuse.search(query, { limit });
|
||||||
|
|
||||||
|
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
|
||||||
|
const item = result.item;
|
||||||
|
const fuseScore = 15 * (1 - (result.score || 0.5));
|
||||||
|
const score = fuseScore + (item.priority ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: "command" as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
matches: result.matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchDynamicItems(
|
||||||
|
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||||
|
query: string,
|
||||||
|
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||||
|
limit = 10,
|
||||||
|
sortByRecent: boolean = true, // Added option to control sorting
|
||||||
|
): CombinedResult[] {
|
||||||
|
if (!dynamicContentFuse) return [];
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
let items = Array.from(dynamicIdToItemMap.values());
|
||||||
|
if (sortByRecent) {
|
||||||
|
items = items.sort((a, b) => b.dateAdded - a.dateAdded);
|
||||||
|
}
|
||||||
|
return items.slice(0, limit).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score: 80, // Assign a default score for non-searched items
|
||||||
|
item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const searchResults = dynamicContentFuse.search(query, { limit });
|
||||||
|
|
||||||
|
return searchResults.map((result: FuseResult<HydratedIndexItem>) => {
|
||||||
|
const item = result.item;
|
||||||
|
const fuseScore = 10 * (1 - (result.score || 0.5));
|
||||||
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; // Apply boost only if sorting by recent
|
||||||
|
const score = fuseScore + recencyBoost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
matches: result.matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performSearch(
|
||||||
|
query: string,
|
||||||
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
|
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||||
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
|
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||||
|
showRecentFirst: boolean,
|
||||||
|
): Promise<CombinedResult[]> {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// Get all results first
|
||||||
|
const commandResults = searchCommands(
|
||||||
|
commandsFuse,
|
||||||
|
query,
|
||||||
|
commandIdToItemMap,
|
||||||
|
);
|
||||||
|
const commandEndTime = performance.now();
|
||||||
|
const dynamicResults = searchDynamicItems(
|
||||||
|
dynamicContentFuse,
|
||||||
|
query,
|
||||||
|
dynamicIdToItemMap,
|
||||||
|
10,
|
||||||
|
showRecentFirst,
|
||||||
|
);
|
||||||
|
const fuseEndTime = performance.now();
|
||||||
|
|
||||||
|
// Get vector results in parallel
|
||||||
|
let vectorResults: VectorSearchResult[] = [];
|
||||||
|
try {
|
||||||
|
vectorResults = await searchVectors(query, 10);
|
||||||
|
} catch (e) {}
|
||||||
|
const vectorEndTime = performance.now();
|
||||||
|
|
||||||
|
console.log("Vector results:", vectorResults);
|
||||||
|
|
||||||
|
// Log timings
|
||||||
|
console.log(`Command search took ${commandEndTime - startTime} milliseconds`);
|
||||||
|
console.log(
|
||||||
|
`Dynamic search took ${fuseEndTime - commandEndTime} milliseconds`,
|
||||||
|
);
|
||||||
|
console.log(`Vector search took ${vectorEndTime - fuseEndTime} milliseconds`);
|
||||||
|
|
||||||
|
// Create a map to store our final results, using ID as key to avoid duplicates
|
||||||
|
const resultMap = new Map<string, CombinedResult>();
|
||||||
|
|
||||||
|
// Add command results first (they keep their original scores)
|
||||||
|
commandResults.forEach((r) => resultMap.set(r.id, r));
|
||||||
|
|
||||||
|
// Process dynamic results and vector results together
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
// Add dynamic results first
|
||||||
|
dynamicResults.forEach((r) => {
|
||||||
|
seenIds.add(r.id);
|
||||||
|
const vectorMatch = vectorResults.find((v) => v.object.id === r.id);
|
||||||
|
if (vectorMatch) {
|
||||||
|
// If we found it in both searches, combine the scores
|
||||||
|
resultMap.set(r.id, {
|
||||||
|
...r,
|
||||||
|
score: r.score + vectorMatch.similarity * 0.6, // Boost exact matches
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If only in Fuse results, keep as is
|
||||||
|
resultMap.set(r.id, r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now add any vector results we haven't seen yet
|
||||||
|
vectorResults.forEach((v) => {
|
||||||
|
const id = v.object.id;
|
||||||
|
if (!seenIds.has(id)) {
|
||||||
|
// This is a semantic match that Fuse missed - add it with the vector similarity as score
|
||||||
|
resultMap.set(id, {
|
||||||
|
id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score: v.similarity * 0.9, // High base score for semantic matches
|
||||||
|
item: v.object,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and sort by score
|
||||||
|
const results = Array.from(resultMap.values());
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { EmbeddingIndex, getEmbedding, initializeModel } from 'client-vector-search';
|
||||||
|
import type { HydratedIndexItem } from '../../indexing/types';
|
||||||
|
import type { SearchResult } from 'client-vector-search';
|
||||||
|
|
||||||
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
|
|
||||||
|
export async function initVectorSearch() {
|
||||||
|
try {
|
||||||
|
await initializeModel();
|
||||||
|
vectorIndex = new EmbeddingIndex([]);
|
||||||
|
vectorIndex.preloadIndexedDB();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error initializing vector search', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorSearchResult extends SearchResult {
|
||||||
|
object: HydratedIndexItem & { embedding: number[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchVectors(query: string, topK: number = 10): Promise<VectorSearchResult[]> {
|
||||||
|
if (!vectorIndex) await initVectorSearch();
|
||||||
|
|
||||||
|
const queryEmbedding = await getEmbedding(query.slice(0, 100));
|
||||||
|
|
||||||
|
const results = await vectorIndex!.search(queryEmbedding, {
|
||||||
|
topK,
|
||||||
|
useStorage: 'indexedDB',
|
||||||
|
dedupeEntries: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return results as VectorSearchResult[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { SearchResult } from "client-vector-search";
|
||||||
|
import type { HydratedIndexItem } from "../../indexing/types";
|
||||||
|
|
||||||
|
export interface VectorSearchResult extends SearchResult {
|
||||||
|
object: HydratedIndexItem & { embedding: number[] };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
import type { HydratedIndexItem } from "./indexing/types";
|
||||||
|
|
||||||
|
export interface DynamicContentItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
dateAdded: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
actionId: string;
|
||||||
|
renderComponentId: string;
|
||||||
|
renderComponent?: typeof SvelteComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dynamicItems: HydratedIndexItem[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a new set of dynamic items.
|
||||||
|
*/
|
||||||
|
export function loadDynamicItems(items: HydratedIndexItem[]) {
|
||||||
|
dynamicItems = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all currently loaded dynamic items.
|
||||||
|
*/
|
||||||
|
export function getDynamicItems(): HydratedIndexItem[] {
|
||||||
|
return dynamicItems;
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { FuseResultMatch, MatchIndices } from "./core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple utility to remove HTML tags from a string.
|
||||||
|
*/
|
||||||
|
export function stripHtmlTags(html: string): string {
|
||||||
|
if (!html) return "";
|
||||||
|
return html.replace(/<[^>]*>/g, "").replace("\n", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes HTML tags from a string, but preserves <span class="highlight"> tags.
|
||||||
|
*/
|
||||||
|
export function stripHtmlButKeepHighlights(html: string): string {
|
||||||
|
if (!html) return "";
|
||||||
|
// Use a placeholder for highlight tags, strip others, then restore placeholders.
|
||||||
|
const highlightOpenPlaceholder = "__HIGHLIGHT_OPEN__";
|
||||||
|
const highlightClosePlaceholder = "__HIGHLIGHT_CLOSE__";
|
||||||
|
|
||||||
|
let processed = html.replace(
|
||||||
|
/<span class="highlight">/g,
|
||||||
|
highlightOpenPlaceholder,
|
||||||
|
);
|
||||||
|
processed = processed.replace(/<\/span>/g, (match, offset, fullString) => {
|
||||||
|
// Only replace </span> if it likely corresponds to our highlight span
|
||||||
|
// This is imperfect but helps avoid replacing unrelated spans.
|
||||||
|
// Look backwards for the nearest opening placeholder.
|
||||||
|
const lastPlaceholder = fullString.lastIndexOf(
|
||||||
|
highlightOpenPlaceholder,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (lastPlaceholder !== -1) {
|
||||||
|
// Check if there's another opening tag between the placeholder and the closing span
|
||||||
|
const interveningContent = fullString.substring(
|
||||||
|
lastPlaceholder + highlightOpenPlaceholder.length,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (!/<span/i.test(interveningContent)) {
|
||||||
|
return highlightClosePlaceholder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match; // Keep the original </span> if unsure
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strip all remaining HTML tags
|
||||||
|
processed = processed.replace(/<[^>]*>/g, "");
|
||||||
|
|
||||||
|
// Restore the highlight tags
|
||||||
|
processed = processed.replace(
|
||||||
|
new RegExp(highlightOpenPlaceholder, "g"),
|
||||||
|
'<span class="highlight">',
|
||||||
|
);
|
||||||
|
processed = processed.replace(
|
||||||
|
new RegExp(highlightClosePlaceholder, "g"),
|
||||||
|
"</span>",
|
||||||
|
);
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightMatch(
|
||||||
|
text: string,
|
||||||
|
term: string,
|
||||||
|
matches?: readonly FuseResultMatch[],
|
||||||
|
): string {
|
||||||
|
if (!term.trim() || !matches || matches.length === 0) return text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find matches for the text field or allContent that contains the text
|
||||||
|
const fieldMatches = matches.find(
|
||||||
|
(match) =>
|
||||||
|
match.key === "text" ||
|
||||||
|
(match.key === "allContent" && match.value?.includes(text)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fieldMatches ||
|
||||||
|
!fieldMatches.indices ||
|
||||||
|
fieldMatches.indices.length === 0
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of character positions to mark which ones need highlighting
|
||||||
|
const highlightMap = new Array(text.length).fill(false);
|
||||||
|
|
||||||
|
fieldMatches.indices.forEach((indices: MatchIndices) => {
|
||||||
|
const start = indices[0];
|
||||||
|
const end = indices[1];
|
||||||
|
|
||||||
|
if (fieldMatches.key === "allContent") {
|
||||||
|
// Find where our text appears in the allContent
|
||||||
|
const allContent = fieldMatches.value;
|
||||||
|
const textPos = allContent?.indexOf(text) ?? -1;
|
||||||
|
|
||||||
|
// Only highlight if the match overlaps with our text
|
||||||
|
if (textPos >= 0) {
|
||||||
|
// Adjust start and end to be relative to our text field
|
||||||
|
const relStart = start - textPos;
|
||||||
|
const relEnd = end - textPos;
|
||||||
|
|
||||||
|
// Only highlight if the match actually overlaps with our text field
|
||||||
|
if (relEnd >= 0 && relStart < text.length) {
|
||||||
|
// Mark the overlapping characters
|
||||||
|
for (
|
||||||
|
let i = Math.max(0, relStart);
|
||||||
|
i <= Math.min(text.length - 1, relEnd);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular text field match - ensure indices are within bounds
|
||||||
|
if (start >= 0 && end < text.length) {
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
let inHighlight = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (highlightMap[i] && !inHighlight) {
|
||||||
|
result += '<span class="highlight">';
|
||||||
|
inHighlight = true;
|
||||||
|
} else if (!highlightMap[i] && inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
inHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += text.charAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error highlighting match:", e);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to extract and highlight content snippet using Fuse matches
|
||||||
|
export function highlightSnippet(
|
||||||
|
content: string,
|
||||||
|
term: string,
|
||||||
|
matches?: readonly FuseResultMatch[],
|
||||||
|
): string {
|
||||||
|
if (!content || !term.trim() || !matches || matches.length === 0)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find matches for content field or allContent that contains the content
|
||||||
|
const contentMatches = matches.find(
|
||||||
|
(match) =>
|
||||||
|
match.key === "content" ||
|
||||||
|
(match.key === "allContent" && match.value?.includes(content)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!contentMatches ||
|
||||||
|
!contentMatches.indices ||
|
||||||
|
contentMatches.indices.length === 0
|
||||||
|
) {
|
||||||
|
// No content matches, return plain content
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + "..." : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the match indices
|
||||||
|
let allIndices: MatchIndices[] = contentMatches.indices as MatchIndices[];
|
||||||
|
|
||||||
|
// If matching against allContent, adjust indices to be relative to content
|
||||||
|
if (contentMatches.key === "allContent") {
|
||||||
|
const allContent = contentMatches.value;
|
||||||
|
const contentPos = allContent?.indexOf(content) ?? -1;
|
||||||
|
|
||||||
|
if (contentPos >= 0) {
|
||||||
|
// Adjust indices to be relative to the content field
|
||||||
|
allIndices = allIndices
|
||||||
|
.map(
|
||||||
|
(indices) =>
|
||||||
|
[
|
||||||
|
indices[0] - contentPos,
|
||||||
|
indices[1] - contentPos,
|
||||||
|
] as MatchIndices,
|
||||||
|
)
|
||||||
|
.filter((indices) => indices[1] >= 0 && indices[0] < content.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allIndices.length === 0) {
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + "..." : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a good center point for our snippet (average of first match)
|
||||||
|
const firstMatch = allIndices[0];
|
||||||
|
const matchCenter = Math.floor((firstMatch[0] + firstMatch[1]) / 2);
|
||||||
|
|
||||||
|
// Extract a window around the match
|
||||||
|
const windowSize = 100;
|
||||||
|
const start = Math.max(0, matchCenter - windowSize / 2);
|
||||||
|
const end = Math.min(content.length, matchCenter + windowSize / 2);
|
||||||
|
|
||||||
|
// Create the basic snippet
|
||||||
|
let snippet = content.substring(start, end);
|
||||||
|
if (start > 0) snippet = "..." + snippet;
|
||||||
|
if (end < content.length) snippet += "...";
|
||||||
|
|
||||||
|
// Create a highlighting map for the snippet
|
||||||
|
const snippetLength = snippet.length;
|
||||||
|
const highlightMap = new Array(snippetLength).fill(false);
|
||||||
|
|
||||||
|
// Calculate offset for the highlighting
|
||||||
|
const startOffset = start > 0 ? start - 3 : start; // Account for '...' if present
|
||||||
|
|
||||||
|
// Mark each matched character in the snippet
|
||||||
|
allIndices.forEach((indices: MatchIndices) => {
|
||||||
|
const matchStart = indices[0];
|
||||||
|
const matchEnd = indices[1];
|
||||||
|
|
||||||
|
// Skip matches outside our snippet window
|
||||||
|
if (matchEnd < start || matchStart > end) return;
|
||||||
|
|
||||||
|
// Adjust match indices to be relative to snippet
|
||||||
|
const snippetMatchStart = Math.max(0, matchStart - startOffset);
|
||||||
|
const snippetMatchEnd = Math.min(
|
||||||
|
snippetLength - 1,
|
||||||
|
matchEnd - startOffset,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark characters for highlighting
|
||||||
|
for (let i = snippetMatchStart; i <= snippetMatchEnd; i++) {
|
||||||
|
if (i >= 0 && i < snippetLength) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the highlighted snippet
|
||||||
|
let result = "";
|
||||||
|
let inHighlight = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < snippetLength; i++) {
|
||||||
|
// If highlighting state changes, add appropriate tags
|
||||||
|
if (highlightMap[i] && !inHighlight) {
|
||||||
|
result += '<span class="highlight">';
|
||||||
|
inHighlight = true;
|
||||||
|
} else if (!highlightMap[i] && inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
inHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the current character
|
||||||
|
result += snippet.charAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close highlight tag if we're still in one at the end
|
||||||
|
if (inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error highlighting snippet:", e);
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + "..." : content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,13 @@ const testPlugin: Plugin<typeof settings> = {
|
|||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
console.log('Test plugin running');
|
console.log('Test plugin running');
|
||||||
|
|
||||||
|
api.events.on('ping', (data) => {
|
||||||
|
console.log('Ping received! Page changed to: ', data);
|
||||||
|
});
|
||||||
|
|
||||||
const { unregister } = api.seqta.onPageChange((page) => {
|
const { unregister } = api.seqta.onPageChange((page) => {
|
||||||
console.log('Page changed to', page);
|
//console.log('Page changed to', page);
|
||||||
|
api.events.emit('ping', page);
|
||||||
|
|
||||||
console.log('Current setting value:', api.settings.someSetting);
|
console.log('Current setting value:', api.settings.someSetting);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import notificationCollectorPlugin from './built-in/notificationCollector';
|
|||||||
import themesPlugin from './built-in/themes';
|
import themesPlugin from './built-in/themes';
|
||||||
import animatedBackgroundPlugin from './built-in/animatedBackground';
|
import animatedBackgroundPlugin from './built-in/animatedBackground';
|
||||||
import assessmentsAveragePlugin from './built-in/assessmentsAverage';
|
import assessmentsAveragePlugin from './built-in/assessmentsAverage';
|
||||||
|
import globalSearchPlugin from './built-in/globalSearch/src/core';
|
||||||
|
import testPlugin from './built-in/test';
|
||||||
|
|
||||||
// Initialize plugin manager
|
// Initialize plugin manager
|
||||||
const pluginManager = PluginManager.getInstance();
|
const pluginManager = PluginManager.getInstance();
|
||||||
|
|
||||||
@@ -15,7 +18,8 @@ pluginManager.registerPlugin(animatedBackgroundPlugin);
|
|||||||
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||||
pluginManager.registerPlugin(notificationCollectorPlugin);
|
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||||
pluginManager.registerPlugin(timetablePlugin);
|
pluginManager.registerPlugin(timetablePlugin);
|
||||||
//pluginManager.registerPlugin(testPlugin);
|
pluginManager.registerPlugin(globalSearchPlugin);
|
||||||
|
pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
export { init as Monofile } from './monofile';
|
export { init as Monofile } from './monofile';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -33,5 +33,5 @@
|
|||||||
"node"
|
"node"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "src/interface/+layout.sveltes"]
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "src/interface/+layout.svelte", "declarations.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -1,7 +1,9 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import path, { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import fs from 'fs';
|
|
||||||
import { updateManifestPlugin } from './lib/patchPackage';
|
import { updateManifestPlugin } from './lib/patchPackage';
|
||||||
|
import touchGlobalCSSPlugin from './lib/touchGlobalCSS';
|
||||||
|
import InlineWorkerPlugin from './lib/inlineWorker';
|
||||||
import { base64Loader } from './lib/base64loader';
|
import { base64Loader } from './lib/base64loader';
|
||||||
import type { BuildTarget } from './lib/types';
|
import type { BuildTarget } from './lib/types';
|
||||||
import ClosePlugin from './lib/closePlugin';
|
import ClosePlugin from './lib/closePlugin';
|
||||||
@@ -19,7 +21,6 @@ import { opera } from './src/manifests/opera';
|
|||||||
import { safari } from './src/manifests/safari';
|
import { safari } from './src/manifests/safari';
|
||||||
import { crx } from '@crxjs/vite-plugin';
|
import { crx } from '@crxjs/vite-plugin';
|
||||||
|
|
||||||
import touchGlobalCSSPlugin from './lib/touchGlobalCSS';
|
|
||||||
const targets: BuildTarget[] = [
|
const targets: BuildTarget[] = [
|
||||||
chrome, brave, edge, firefox, opera, safari
|
chrome, brave, edge, firefox, opera, safari
|
||||||
]
|
]
|
||||||
@@ -30,6 +31,7 @@ const mode = process.env.MODE || 'chrome'; // Check the environment variable to
|
|||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
plugins: [
|
plugins: [
|
||||||
base64Loader,
|
base64Loader,
|
||||||
|
InlineWorkerPlugin(),
|
||||||
svelte({
|
svelte({
|
||||||
emitCss: false
|
emitCss: false
|
||||||
}),
|
}),
|
||||||
@@ -70,6 +72,9 @@ export default defineConfig(({ command }) => ({
|
|||||||
legacy: {
|
legacy: {
|
||||||
skipWebSocketTokenCheck: true,
|
skipWebSocketTokenCheck: true,
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: resolve(__dirname, 'dist', mode),
|
outDir: resolve(__dirname, 'dist', mode),
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user