From d3d9b45caa40ea01c433bd397159607d561805cf Mon Sep 17 00:00:00 2001 From: SethBurkart123 Date: Mon, 5 May 2025 19:49:19 +1000 Subject: [PATCH] feat: forums + improvements --- .../src/components/AssessmentItem.svelte | 50 -------------- .../src/components/SearchBar.svelte | 43 +++++++++--- .../components/items/AssessmentItem.svelte | 32 +++++++++ .../src/components/items/ForumItem.svelte | 27 ++++++++ .../built-in/globalSearch/src/core/styles.css | 13 ++++ .../built-in/globalSearch/src/core/types.ts | 4 +- .../globalSearch/src/indexing/indexer.ts | 50 ++------------ .../globalSearch/src/indexing/jobs.ts | 2 + .../globalSearch/src/indexing/jobs/forums.ts | 69 +++++++++++++++++++ .../src/indexing/renderComponents.ts | 11 ++- .../globalSearch/src/indexing/types.ts | 4 -- .../src/indexing/worker/vectorWorker.ts | 10 +-- .../indexing/worker/vectorWorkerManager.ts | 7 +- .../globalSearch/src/search/searchUtils.ts | 15 ++-- .../src/search/vector/vectorSearch.ts | 4 +- .../src/search/vector/vectorTypes.ts | 4 +- .../globalSearch/src/utils/dynamicItems.ts | 8 +-- 17 files changed, 217 insertions(+), 136 deletions(-) delete mode 100644 src/plugins/built-in/globalSearch/src/components/AssessmentItem.svelte create mode 100644 src/plugins/built-in/globalSearch/src/components/items/AssessmentItem.svelte create mode 100644 src/plugins/built-in/globalSearch/src/components/items/ForumItem.svelte create mode 100644 src/plugins/built-in/globalSearch/src/indexing/jobs/forums.ts diff --git a/src/plugins/built-in/globalSearch/src/components/AssessmentItem.svelte b/src/plugins/built-in/globalSearch/src/components/AssessmentItem.svelte deleted file mode 100644 index 76a0e7f5..00000000 --- a/src/plugins/built-in/globalSearch/src/components/AssessmentItem.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte index 41002345..8e8135c7 100644 --- a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte +++ b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte @@ -10,8 +10,9 @@ import Fuse from 'fuse.js'; import Calculator from './Calculator.svelte'; import { actionMap } from '../indexing/actions'; - import type { IndexItem, HydratedIndexItem } from '../indexing/types'; + import type { IndexItem } from '../indexing/types'; import debounce from 'lodash/debounce'; + import { renderComponentMap } from '../indexing/renderComponents'; const { transparencyEffects, @@ -22,9 +23,9 @@ }>(); let commandsFuse = $state>(); - let dynamicContentFuse = $state>(); + let dynamicContentFuse = $state>(); - const dynamicIdToItemMap = $state(new Map()); + const dynamicIdToItemMap = $state(new Map()); const commandIdToItemMap = $state(new Map()); let isIndexing = $state(false); @@ -75,6 +76,7 @@ let combinedResults = $state([]); let isLoading = $state(false); let calculatorResult = $state(null); + let resultsList = $state(); const updateCalculatorState = (hasResult: string | null) => { calculatorResult = hasResult; @@ -141,12 +143,20 @@ searchTerm = ''; selectedIndex = 0; combinedResults = []; + tick().then(() => { + const selectedElement = resultsList?.querySelector(`li:nth-child(1)`); + selectedElement?.scrollIntoView({ block: 'nearest' }); + }); } }); $effect(() => { if (combinedResults.length === 0 && calculatorResult && commandPalleteOpen) { selectedIndex = 0; + tick().then(() => { + const selectedElement = resultsList?.querySelector(`li:nth-child(1)`); + selectedElement?.scrollIntoView({ block: 'nearest' }); + }); } }); @@ -154,16 +164,24 @@ const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1; if (selectedIndex < maxIndex) { selectedIndex++; + tick().then(() => { + const selectedElement = resultsList?.querySelector(`li:nth-child(${selectedIndex + 1})`); + selectedElement?.scrollIntoView({ block: 'nearest' }); + }); } }; const selectPrev = () => { if (selectedIndex > 0) { selectedIndex--; + tick().then(() => { + const selectedElement = resultsList?.querySelector(`li:nth-child(${selectedIndex + 1})`); + selectedElement?.scrollIntoView({ block: 'nearest' }); + }); } }; - function executeItemAction(item: StaticCommandItem | HydratedIndexItem) { + function executeItemAction(item: StaticCommandItem | IndexItem) { if ('action' in item && typeof item.action === 'function') { (item as StaticCommandItem).action(); } else if ('actionId' in item && item.actionId && actionMap[item.actionId]) { @@ -240,7 +258,10 @@ /> -
    +
      {:else if result.type === 'dynamic'} - {@const dynamicItem = item as HydratedIndexItem} - {#if dynamicItem.renderComponent} - executeItemAction(dynamicItem)} + onclick={() => executeItemAction(dynamicItem)} + onkeydown={() => {}} + role="button" + tabindex="0" /> {:else} \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/components/items/ForumItem.svelte b/src/plugins/built-in/globalSearch/src/components/items/ForumItem.svelte new file mode 100644 index 00000000..fa2865fd --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/components/items/ForumItem.svelte @@ -0,0 +1,27 @@ + + + \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/core/styles.css b/src/plugins/built-in/globalSearch/src/core/styles.css index 89f902f9..5ea89f6b 100644 --- a/src/plugins/built-in/globalSearch/src/core/styles.css +++ b/src/plugins/built-in/globalSearch/src/core/styles.css @@ -56,3 +56,16 @@ color: #aaa; } } + + +.highlight { + background-color: rgba(255, 213, 0, 0.3); + font-weight: 500; + border-radius: 2px; + padding: 0 1px; + margin: 0 -1px; +} + +.dark .highlight { + background-color: rgba(255, 230, 100, 0.4); +} \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/core/types.ts b/src/plugins/built-in/globalSearch/src/core/types.ts index ce529485..bade23a6 100644 --- a/src/plugins/built-in/globalSearch/src/core/types.ts +++ b/src/plugins/built-in/globalSearch/src/core/types.ts @@ -1,5 +1,5 @@ import type { StaticCommandItem } from "./commands"; -import type { HydratedIndexItem } from "../indexing/types"; +import type { IndexItem } from "../indexing/types"; export interface MatchIndices { readonly 0: number; @@ -16,7 +16,7 @@ export interface CombinedResult { id: string; type: "command" | "dynamic"; score: number; - item: StaticCommandItem | HydratedIndexItem; + item: StaticCommandItem | IndexItem; matches?: readonly FuseResultMatch[]; } diff --git a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts index 9dd2585d..83a42ea2 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts @@ -1,7 +1,7 @@ import { clear, getAll, get, put, remove } from "./db"; import { jobs } from "./jobs"; import { renderComponentMap } from "./renderComponents"; -import type { HydratedIndexItem, IndexItem, Job, JobContext } from "./types"; +import type { IndexItem, Job, JobContext } from "./types"; import { VectorWorkerManager } from "./worker/vectorWorkerManager"; const META_STORE = "meta"; @@ -83,21 +83,14 @@ function dispatchProgress( window.dispatchEvent(event); } -export async function loadAllStoredItems(): Promise { - const all: HydratedIndexItem[] = []; +export async function loadAllStoredItems(): Promise { + const all: IndexItem[] = []; 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) { if ( @@ -108,10 +101,7 @@ export async function loadAllStoredItems(): Promise { item.actionId && job.renderComponentId ) { - all.push({ - ...item, - renderComponent: renderComponent || undefined, - }); + all.push(item); } else { console.warn(`Skipping invalid item from job ${jobId}:`, item); } @@ -144,7 +134,7 @@ export async function runIndexing(): Promise { const totalSteps = jobIds.length + 1; dispatchProgress(completedJobs, totalSteps, true, "Starting jobs"); - const allItemsFromJobs: HydratedIndexItem[] = []; + const allItemsFromJobs: IndexItem[] = []; // --- Step 1: Run Fetching/Storing Jobs (Main Thread) --- for (const jobId of jobIds) { @@ -225,35 +215,7 @@ export async function runIndexing(): Promise { await setStoredItems(merged); 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); + allItemsFromJobs.push(...newItemsRaw); console.debug( `%c[Indexer] ${job.label}: ${newItemsRaw.length} new items from run, ${merged.length} total stored in '${jobId}' store (non-vector).`, diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs.ts index 92be1443..7d2c5506 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs.ts @@ -1,8 +1,10 @@ import type { Job } from "./types"; import { messagesJob } from "./jobs/messages"; import { assessmentsJob } from "./jobs/assessments"; +import { forumsJob } from "./jobs/forums"; export const jobs: Record = { messages: messagesJob, assessments: assessmentsJob, + forums: forumsJob, }; diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/forums.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/forums.ts new file mode 100644 index 00000000..151c57bd --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/forums.ts @@ -0,0 +1,69 @@ +import type { Job, IndexItem } from "../types"; + +const fetchForums = async () => { + const res = await fetch(`${location.origin}/seqta/student/load/forums`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ mode: "list" }), + }); + + return res.json() as Promise<{ + payload: { forums: any[] }; + status: string; + }>; +}; + +export const forumsJob: Job = { + id: "forums", + label: "Forums", + renderComponentId: "forum", + //frequency: { type: "expiry", afterMs: 30 * 24 * 60 * 60 * 1000 }, // 30 days + frequency: { type: "interval", ms: 1000 }, // 1s + + run: async (ctx) => { + const existingIds = new Set( + (await ctx.getStoredItems("forums")).map((i) => i.id), + ); + + let list; + try { + list = await fetchForums(); + } catch (e) { + console.error("[Forums job] list fetch failed:", e); + return []; + } + + if (list.status !== "200") return []; + + const items: IndexItem[] = []; + + for (const forum of list.payload.forums) { + const id = forum.id.toString(); + if (existingIds.has(id)) continue; + + items.push({ + id, + text: forum.title, + category: "forums", + content: `${forum.title}`, + dateAdded: Date.now(), + metadata: { + forumId: forum.id, + owner: forum.owner, + title: forum.title, + }, + actionId: "forum", + renderComponentId: "forum", + }); + } + + return items; + }, + + /** Keep only forums from the last 2 years. */ + purge: (items) => { + const twoYearsAgo = Date.now() - 2 * 365 * 24 * 60 * 60 * 1000; + return items.filter((i) => i.dateAdded >= twoYearsAgo); + }, +}; \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts b/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts index e0c45651..bbdf344f 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts @@ -1,10 +1,15 @@ import type { SvelteComponent } from "svelte"; -import AssessmentComponent from "../components/AssessmentItem.svelte"; +import AssessmentItem from "../components/items/AssessmentItem.svelte"; +import ForumItem from "../components/items/ForumItem.svelte"; // import other components as needed export const renderComponentMap: Record = { - assessment: AssessmentComponent as unknown as typeof SvelteComponent, - message: AssessmentComponent as unknown as typeof SvelteComponent, + assessment: AssessmentItem as unknown as typeof SvelteComponent, + message: AssessmentItem as unknown as typeof SvelteComponent, + forum: ForumItem as unknown as typeof SvelteComponent, // subject: SubjectComponent, // etc... }; + +void AssessmentItem; +void ForumItem; \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/indexing/types.ts b/src/plugins/built-in/globalSearch/src/indexing/types.ts index 25097d99..11a984eb 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/types.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/types.ts @@ -11,10 +11,6 @@ export interface IndexItem { renderComponentId: string; } -export interface HydratedIndexItem extends IndexItem { - renderComponent: typeof SvelteComponent; -} - export type Frequency = | "pageLoad" | { type: "interval"; ms: number } diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts index eec5a1d4..26cacbd4 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts @@ -1,5 +1,5 @@ import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; -import type { HydratedIndexItem } from "../types"; +import type { IndexItem } from "../types"; let vectorIndex: EmbeddingIndex | null = null; let isInitialized = false; @@ -35,8 +35,8 @@ async function initWorker() { } async function vectorizeItem( - item: HydratedIndexItem, -): Promise<(HydratedIndexItem & { embedding: number[] }) | null> { + item: IndexItem, +): Promise<(IndexItem & { embedding: number[] }) | null> { // Simplified for brevity - assumes embedding function doesn't need cancellation signal try { const textToEmbed = [ @@ -57,7 +57,7 @@ async function vectorizeItem( } } -async function processItems(items: HydratedIndexItem[], signal: AbortSignal) { +async function processItems(items: IndexItem[], signal: AbortSignal) { console.debug("Worker received process request."); if (!vectorIndex) { console.warn( @@ -140,7 +140,7 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) { const vectorizationResults = await Promise.all(batch.map(vectorizeItem)); const successfullyVectorized = vectorizationResults.filter( (result) => result !== null, - ) as (HydratedIndexItem & { embedding: number[] })[]; + ) as (IndexItem & { embedding: number[] })[]; if (signal.aborted) { console.debug("Processing cancelled after vectorization batch."); diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts index 9a629efa..efcc9cfd 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts @@ -1,5 +1,5 @@ import { refreshVectorCache } from "../../search/vector/vectorSearch"; -import type { HydratedIndexItem } from "../types"; +import type { IndexItem } from "../types"; import vectorWorker from "./vectorWorker.ts?inlineWorker"; import type { SearchResult } from "embeddia"; @@ -156,7 +156,7 @@ export class VectorWorkerManager { } async processItems( - items: HydratedIndexItem[], + items: IndexItem[], onProgress?: ProgressCallback, ) { await this.ensureReady(); // Wait for worker to be ready @@ -168,11 +168,10 @@ export class VectorWorkerManager { console.debug(`Sending ${items.length} items to worker for processing.`); - const serialisableItems = items.map(({ renderComponent, ...rest }) => rest); this.worker!.postMessage({ type: "process", - data: { items: serialisableItems }, + data: { items: items }, }); } diff --git a/src/plugins/built-in/globalSearch/src/search/searchUtils.ts b/src/plugins/built-in/globalSearch/src/search/searchUtils.ts index 3dc116da..9405ebe2 100644 --- a/src/plugins/built-in/globalSearch/src/search/searchUtils.ts +++ b/src/plugins/built-in/globalSearch/src/search/searchUtils.ts @@ -2,7 +2,7 @@ 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 type { IndexItem } from "../indexing/types"; import { searchVectors } from "./vector/vectorSearch"; import type { VectorSearchResult } from "./vector/vectorTypes"; @@ -41,7 +41,7 @@ export function createSearchIndexes() { dynamicContentFuse: new Fuse( dynamicItems, dynamicOptions, - ) as Fuse, + ) as Fuse, commands, dynamicItems, }; @@ -85,9 +85,9 @@ export function searchCommands( } export function searchDynamicItems( - dynamicContentFuse: Fuse, + dynamicContentFuse: Fuse, query: string, - dynamicIdToItemMap: Map, + dynamicIdToItemMap: Map, limit = 10, sortByRecent: boolean = true, // Added option to control sorting ): CombinedResult[] { @@ -109,7 +109,7 @@ export function searchDynamicItems( const now = Date.now(); const searchResults = dynamicContentFuse.search(query, { limit }); - return searchResults.map((result: FuseResult) => { + return searchResults.map((result: FuseResult) => { const item = result.item; const fuseScore = 10 * (1 - (result.score || 0.5)); const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24); @@ -129,9 +129,9 @@ export function searchDynamicItems( export async function performSearch( query: string, commandsFuse: Fuse, - dynamicContentFuse: Fuse, + dynamicContentFuse: Fuse, commandIdToItemMap: Map, - dynamicIdToItemMap: Map, + dynamicIdToItemMap: Map, showRecentFirst: boolean, ): Promise { // Get all results first @@ -182,6 +182,7 @@ export async function performSearch( // 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, { diff --git a/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts b/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts index 124ae00d..a67fe405 100644 --- a/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts +++ b/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts @@ -1,5 +1,5 @@ import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; -import type { HydratedIndexItem } from "../../indexing/types"; +import type { IndexItem } from "../../indexing/types"; import type { SearchResult } from "embeddia"; let vectorIndex: EmbeddingIndex | null = null; @@ -15,7 +15,7 @@ export async function initVectorSearch() { } export interface VectorSearchResult extends SearchResult { - object: HydratedIndexItem & { embedding: number[] }; + object: IndexItem & { embedding: number[] }; } export async function searchVectors( diff --git a/src/plugins/built-in/globalSearch/src/search/vector/vectorTypes.ts b/src/plugins/built-in/globalSearch/src/search/vector/vectorTypes.ts index ed000e46..515c7d62 100644 --- a/src/plugins/built-in/globalSearch/src/search/vector/vectorTypes.ts +++ b/src/plugins/built-in/globalSearch/src/search/vector/vectorTypes.ts @@ -1,6 +1,6 @@ import type { SearchResult } from "embeddia"; -import type { HydratedIndexItem } from "../../indexing/types"; +import type { IndexItem } from "../../indexing/types"; export interface VectorSearchResult extends SearchResult { - object: HydratedIndexItem & { embedding: number[] }; + object: IndexItem & { embedding: number[] }; } diff --git a/src/plugins/built-in/globalSearch/src/utils/dynamicItems.ts b/src/plugins/built-in/globalSearch/src/utils/dynamicItems.ts index 5c275267..d1f0b8c7 100644 --- a/src/plugins/built-in/globalSearch/src/utils/dynamicItems.ts +++ b/src/plugins/built-in/globalSearch/src/utils/dynamicItems.ts @@ -1,5 +1,5 @@ import type { SvelteComponent } from "svelte"; -import type { HydratedIndexItem } from "./indexing/types"; +import type { IndexItem } from "./indexing/types"; export interface DynamicContentItem { id: string; @@ -13,18 +13,18 @@ export interface DynamicContentItem { renderComponent?: typeof SvelteComponent; } -let dynamicItems: HydratedIndexItem[] = []; +let dynamicItems: IndexItem[] = []; /** * Loads a new set of dynamic items. */ -export function loadDynamicItems(items: HydratedIndexItem[]) { +export function loadDynamicItems(items: IndexItem[]) { dynamicItems = items; } /** * Returns all currently loaded dynamic items. */ -export function getDynamicItems(): HydratedIndexItem[] { +export function getDynamicItems(): IndexItem[] { return dynamicItems; }