472 lines
13 KiB
TypeScript
472 lines
13 KiB
TypeScript
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<DecorationSet> {
|
|
return new Plugin<DecorationSet>({
|
|
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<HTMLDivElement>();
|
|
const modelSelectRef = useSignal<HTMLSelectElement>();
|
|
const models = useSignal<string[]>([]);
|
|
const currentModel = useSignal("all-mpnet-base-v2");
|
|
const syncState = useSignal<SyncState>("clean");
|
|
const editorView = useSignal<NoSerialize<EditorView>>();
|
|
const sentenceDecorations = useSignal<SentenceDecoration[]>([]);
|
|
const salienceData = useSignal<SalienceData | null>(null);
|
|
const debounceTimer = useSignal<number | null>(null);
|
|
const pendingRequest = useSignal<AbortController | null>(null);
|
|
const debugPanelOpen = useSignal(false);
|
|
const adjacencyMatrix = useSignal<number[][] | null>(null);
|
|
const autoSolveEnabled = useSignal(true);
|
|
const parameters = useStore<SalienceParameters>({
|
|
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 (
|
|
<>
|
|
<div class="container">
|
|
<h1>
|
|
Salience
|
|
<span class="subtitle">
|
|
sentence highlights based on their significance to the document
|
|
</span>
|
|
</h1>
|
|
<a href="./about" class="about-link text-sm">How it works →</a>
|
|
<div class="controls">
|
|
<label for="model-select">Model:</label>
|
|
<select
|
|
id="model-select"
|
|
ref={modelSelectRef}
|
|
onChange$={handleModelChange}
|
|
>
|
|
{models.value.length === 0 ? (
|
|
<option>Loading...</option>
|
|
) : (
|
|
models.value.map((model) => (
|
|
<option
|
|
key={model}
|
|
value={model}
|
|
selected={model === currentModel.value}
|
|
>
|
|
{model}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
<label class="auto-solve-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoSolveEnabled.value}
|
|
onChange$={(e) => {
|
|
autoSolveEnabled.value = (e.target as HTMLInputElement).checked;
|
|
}}
|
|
/>
|
|
Auto highlight colors
|
|
</label>
|
|
<span class={`status-badge ${syncState.value}`}>
|
|
{syncState.value === "clean"
|
|
? "Synchronized"
|
|
: syncState.value === "dirty"
|
|
? "Modified"
|
|
: "Processing..."}
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref={editorRef}
|
|
class="editor"
|
|
/>
|
|
</div>
|
|
|
|
<DebugPanel
|
|
isOpen={debugPanelOpen}
|
|
parameters={parameters}
|
|
adjacencyMatrix={adjacencyMatrix}
|
|
/>
|
|
</>
|
|
);
|
|
});
|
|
|
|
// Shared function to fetch salience data from API
|
|
async function fetchSalienceData(
|
|
model: string,
|
|
view: EditorView,
|
|
syncState: Signal<SyncState>,
|
|
sentenceDecorations: Signal<SentenceDecoration[]>,
|
|
salienceData: Signal<SalienceData | null>,
|
|
pendingRequest: Signal<AbortController | null>,
|
|
adjacencyMatrix: Signal<number[][] | null>,
|
|
) {
|
|
// 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<SyncState>,
|
|
sentenceDecorations: Signal<SentenceDecoration[]>,
|
|
salienceData: Signal<SalienceData | null>,
|
|
debounceTimer: Signal<number | null>,
|
|
pendingRequest: Signal<AbortController | null>,
|
|
adjacencyMatrix: Signal<number[][] | null>,
|
|
) {
|
|
// 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",
|
|
},
|
|
],
|
|
};
|