import { component$, useSignal, useVisibleTask$, $, noSerialize, useStore, type Signal, type NoSerialize, } from "@builder.io/qwik"; import { type DocumentHead, routeLoader$ } from "@builder.io/qwik-city"; import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { EditorView, Decoration, DecorationSet } from "prosemirror-view"; import { Schema, Node as PMNode } from "prosemirror-model"; import { schema as basicSchema } from "prosemirror-schema-basic"; import { keymap } from "prosemirror-keymap"; import { baseKeymap } from "prosemirror-commands"; import * as math from "mathjs"; import exampleDoc from "~/assets/example-doc-1.txt?raw"; import { DebugPanel } from "~/components/debug-panel/debug-panel"; import { autoSolveParameters } from "~/utils/autosolver"; import "~/global.css"; // API Configuration const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:5000'; // Types interface SalienceData { source: string; intervals: [number, number][]; adjacency: number[][]; } interface SentenceDecoration { from: number; to: number; salience: number; } interface SalienceParameters { exponent: number; threshold: number; randomWalkLength: number; } type SyncState = "clean" | "dirty" | "loading"; // Create schema with marks const salienceSchema = new Schema({ nodes: basicSchema.spec.nodes, marks: basicSchema.spec.marks, }); // Create decorations from sentence decorations function createDecorations( doc: PMNode, decorations: SentenceDecoration[] ): DecorationSet { const decos = decorations.map((deco) => { return Decoration.inline(deco.from, deco.to, { class: "sentence", style: `--salience: ${deco.salience}`, }); }); return DecorationSet.create(doc, decos); } // Plugin to manage salience decorations function saliencePlugin( initialDecorations: SentenceDecoration[] ): Plugin { return new Plugin({ state: { init(_, { doc }) { return createDecorations(doc, initialDecorations); }, apply(tr, old) { // Get updated decorations from transaction metadata const newDecorations = tr.getMeta("updateDecorations"); if (newDecorations) { return createDecorations(tr.doc, newDecorations); } // Map existing decorations through the transaction return old.map(tr.mapping, tr.doc); }, }, props: { decorations(state) { return this.getState(state); }, }, }); } // Translate decorations through a transaction function translateDecorations( decorations: SentenceDecoration[], tr: Transaction ): SentenceDecoration[] { return decorations .map((deco) => { const from = tr.mapping.map(deco.from); const to = tr.mapping.map(deco.to); // Only keep decorations that still have valid ranges if (from < to) { return { ...deco, from, to }; } return null; }) .filter((deco): deco is SentenceDecoration => deco !== null); } // Load initial document export const useInitialDocument = routeLoader$(() => { return exampleDoc; }); export default component$(() => { const initialDocument = useInitialDocument(); const editorRef = useSignal(); const modelSelectRef = useSignal(); const models = useSignal([]); const currentModel = useSignal("all-mpnet-base-v2"); const syncState = useSignal("clean"); const editorView = useSignal>(); const sentenceDecorations = useSignal([]); const salienceData = useSignal(null); const debounceTimer = useSignal(null); const pendingRequest = useSignal(null); const debugPanelOpen = useSignal(false); const adjacencyMatrix = useSignal(null); const autoSolveEnabled = useSignal(true); const parameters = useStore({ exponent: 3, threshold: 0.95, randomWalkLength: 5, }); // Auto-solve parameters when adjacency matrix changes (if enabled) // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(({ track }) => { track(() => adjacencyMatrix.value); track(() => autoSolveEnabled.value); if (!adjacencyMatrix.value || !autoSolveEnabled.value) { return; } const solved = autoSolveParameters(adjacencyMatrix.value, parameters); if (solved.exponent !== undefined) { parameters.exponent = solved.exponent; } if (solved.threshold !== undefined) { parameters.threshold = solved.threshold; } }); // Compute salience scores when adjacency matrix or parameters change // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(({ track }) => { track(() => adjacencyMatrix.value); track(() => parameters.exponent); track(() => parameters.threshold); track(() => parameters.randomWalkLength); if (!adjacencyMatrix.value || sentenceDecorations.value.length === 0) { return; } // Compute scores using random walk const initial = adjacencyMatrix.value.map(() => 1); const scores = math.multiply( initial, math.pow(adjacencyMatrix.value, parameters.randomWalkLength) as number[][] ) as number[]; // Update salience values in existing decorations sentenceDecorations.value = sentenceDecorations.value.map((deco, i) => { const exponentialSpread = scores[i] ** parameters.exponent; const shiftedDown = exponentialSpread - parameters.threshold; const clamped = Math.max(0, Math.min(1, shiftedDown)); return { ...deco, salience: clamped, }; }); }); // Update editor when decorations change // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(({ track }) => { track(() => sentenceDecorations.value); if (!editorView.value || sentenceDecorations.value.length === 0) { return; } const updateTr = editorView.value.state.tr.setMeta( "updateDecorations", sentenceDecorations.value ); editorView.value.dispatch(updateTr); }); // Load available models // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(async () => { try { const res = await fetch(`${API_BASE_URL}/models`); const data = await res.json(); models.value = data; } catch (err) { console.error("Failed to load models:", err); } }); // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(({ track }) => { track(() => editorRef.value); if (!editorRef.value) return; // Parse the initial document text into separate paragraphs // Split by single newlines and preserve blank lines as empty paragraphs const lines = initialDocument.value .split('\n'); const paragraphs: PMNode[] = []; lines.forEach((line) => { // Create paragraph with text if line has content, otherwise empty paragraph const content = line.length > 0 ? [salienceSchema.text(line)] : []; //content.push(salienceSchema.node("hard_break")); paragraphs.push(salienceSchema.node("paragraph", null, content)); }); console.log(paragraphs); const initialDoc = salienceSchema.node("doc", null, paragraphs); console.log(initialDoc.textContent); const state = EditorState.create({ schema: salienceSchema, doc: initialDoc, plugins: [ saliencePlugin([]), keymap(baseKeymap), ], }); const view = new EditorView(editorRef.value, { state, dispatchTransaction: (tr) => { const newState = view.state.apply(tr); view.updateState(newState); // If this was a user edit (not a programmatic update) if (tr.docChanged && !tr.getMeta("fromApi")) { // Translate existing decorations to new positions const translated = translateDecorations(sentenceDecorations.value, tr); sentenceDecorations.value = translated; // The reactive task will update the editor with translated decorations // Mark as dirty and schedule API request syncState.value = "dirty"; scheduleApiRequest( view, currentModel.value, syncState, sentenceDecorations, salienceData, debounceTimer, pendingRequest, adjacencyMatrix, ); } }, }); editorView.value = noSerialize(view); fetchSalienceData( currentModel.value, view, syncState, sentenceDecorations, salienceData, pendingRequest, adjacencyMatrix ); // Cleanup return () => { view.destroy(); }; }); // Handle model change const handleModelChange = $((event: Event) => { const target = event.target as HTMLSelectElement; currentModel.value = target.value; if (editorView.value) { fetchSalienceData( target.value, editorView.value, syncState, sentenceDecorations, salienceData, pendingRequest, adjacencyMatrix, ); } }); return ( <>

Salience sentence highlights based on their significance to the document

How it works →
{syncState.value === "clean" ? "Synchronized" : syncState.value === "dirty" ? "Modified" : "Processing..."}
); }); // Shared function to fetch salience data from API async function fetchSalienceData( model: string, view: EditorView, syncState: Signal, sentenceDecorations: Signal, salienceData: Signal, pendingRequest: Signal, adjacencyMatrix: Signal, ) { // Cancel any pending request if (pendingRequest.value) { pendingRequest.value.abort(); } const controller = new AbortController(); pendingRequest.value = controller; syncState.value = "loading"; // Get current document text const text = view.state.doc.textContent; try { const res = await fetch(`${API_BASE_URL}/salience?model=${encodeURIComponent(model)}`, { method: "POST", headers: { "Content-Type": "text/plain" }, body: text, signal: controller.signal, }); const data: SalienceData = await res.json(); salienceData.value = data; adjacencyMatrix.value = data.adjacency; // Set decorations with positions and zero salience // The reactive task will compute and update the salience values sentenceDecorations.value = data.intervals.map(([start, end]) => ({ from: start, to: end, salience: 0, })); syncState.value = "clean"; pendingRequest.value = null; } catch (err: any) { if (err.name !== "AbortError") { console.error("Failed to load salience:", err); syncState.value = "dirty"; } pendingRequest.value = null; } } // Schedule API request with debouncing function scheduleApiRequest( view: EditorView, model: string, syncState: Signal, sentenceDecorations: Signal, salienceData: Signal, debounceTimer: Signal, pendingRequest: Signal, adjacencyMatrix: Signal, ) { // Clear existing timer if (debounceTimer.value !== null) { clearTimeout(debounceTimer.value); } // Set new timer debounceTimer.value = window.setTimeout(() => { // Check if we're still dirty (not loading) if (syncState.value === "dirty") { fetchSalienceData( model, view, syncState, sentenceDecorations, salienceData, pendingRequest, adjacencyMatrix ); } }, 1000); // 1 second debounce } export const head: DocumentHead = { title: "Salience", meta: [ { name: "description", content: "Sentence highlights based on their significance to the document", }, ], };