import { useEffect, useRef, useState } from "react"; import { referenceOf, useInterface } from "@quixos/camino-react"; import { interfaces } from "./gen/client.js"; import { sameCacheConfiguration } from "./cache.js"; import { accepted, errorMessage, type DocNode, type FeatureNode } from "./model.js"; import { preparationQueue, processorMatches, sameScope } from "./work.js"; import type { Range } from "./canvas/types.js"; import type { ReadingWindow } from "./canvas/pages.js"; import type { AnnotationRender } from "./render-hooks.js"; type Planned = { feature: FeatureNode; range: Range; context: string; planning?: "click" | "hover" }; /** One admission/cache path for proximity preparation, clicks, hover and retry. * A changed window replaces unsent plans. Admitted jobs retain their results. * Model preparation has two slots, shared by independent scopes. */ export function useProcessing({ document: doc, annotationsReady, features, revisions, revisionAliases = {}, computations, renders, window, error, }: { document: DocNode; annotationsReady: boolean; features: FeatureNode[]; revisions: Record; revisionAliases?: Record; computations: Record; renders: Record; window: ReadingWindow; error: (message: string) => void; }) { const ops = useInterface( { plan: interfaces.ReadingDocument.plan, generate: interfaces.ReadingDocument.generate }, referenceOf(doc), ); const jobs = doc.jobs.entries.map(({ node }) => node); const [planning, setPlanning] = useState<{ context: string; items: Planned[] }>({ context: "", items: [] }), [tick, setTick] = useState(0); const [clickScopes, setClickScopes] = useState>({}); const [intent, setIntent] = useState([]); const submitted = useRef(new Set()), admitting = useRef(new Map()), focusSerial = useRef({ click: 0, hover: 0 }); const featureKey = features .map( (f) => `${referenceOf(f).object}:${computations[referenceOf(f).object]}:${f.data.enabled}:${revisions[f.data.processor]}`, ) .join("|"); const context = `${referenceOf(doc).object}:${doc.data.sourceRevision}:${featureKey}`; const currentContext = useRef(context); currentContext.current = context; const planningContext = [ context, window.nearby.start, window.nearby.end, window.visible.start, window.visible.end, ].join(":"); const plans = planning.context === planningContext ? planning.items : []; const matches = (job: (typeof jobs)[number], { feature, range }: Planned) => processorMatches(job.data, revisions[feature.data.processor], revisionAliases[feature.data.processor]) && sameScope( { ...job.data, configuration: job.configuration }, doc.data.sourceRevision, computations[referenceOf(feature).object], range, ); const keyFor = ({ feature, range }: Planned) => [ doc.data.sourceRevision, computations[referenceOf(feature).object], revisions[feature.data.processor], range.start, range.end, ].join(":"); function request(feature: FeatureNode, range: Range, retry = false): boolean { if (!annotationsReady || ops.status !== "ready" || range.end <= range.start || !revisions[feature.data.processor]) return false; const item = { feature, range, context }, key = keyFor(item); if (!retry && (submitted.current.has(key) || jobs.some((job) => matches(job, item)))) return false; submitted.current.add(key); setIntent((previous) => [...previous.filter((item) => keyFor(item) !== key), item]); admitting.current.set(key, item); void accepted( ops.value.generate.call({ feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } }, ...range, retry, createdAt: new Date().toISOString(), }), ).catch((cause) => { admitting.current.delete(key); setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key)); error(errorMessage(cause)); setTick((value) => value + 1); }); return true; } useEffect(() => { let closed = false; setPlanning({ context: planningContext, items: [] }); if (!annotationsReady || ops.status !== "ready" || window.nearby.end <= window.nearby.start) return; const timer = setTimeout(() => { void Promise.all( features .filter((f) => f.data.enabled && f.data.generation !== "focus") .map(async (feature) => { const ranges = await ops.value.plan.call({ feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } }, ...window.nearby, visibleStart: window.visible.start, visibleEnd: window.visible.end, kind: "nearby", }); return ranges.map((range) => ({ feature, range, context })); }), ) .then((all) => { if (!closed) setPlanning({ context: planningContext, items: all.flat() }); }) .catch((cause) => { if (!closed) error(errorMessage(cause)); }); }, 180); return () => { closed = true; clearTimeout(timer); }; }, [planningContext, ops.status, annotationsReady]); useEffect(() => { for (const [key, item] of admitting.current) if (jobs.some((job) => matches(job, item))) admitting.current.delete(key); setIntent((previous) => { const next = previous.filter((item) => item.context === context && !jobs.some((job) => matches(job, item))); return next.length === previous.length ? previous : next; }); const queue = nearbyQueue(); for (const { feature, range } of queue.admit) request(feature, range); }, [planning, doc.jobs.entries, context, tick, planningContext]); function nearbyQueue() { const pending = jobs.filter( (job) => job.data.kind === "annotation" && job.data.status === "queued" && job.data.sourceRevision === doc.data.sourceRevision && features.some( (f) => f.data.enabled && f.data.generation !== "focus" && sameCacheConfiguration(computations[referenceOf(f).object], job.configuration) && processorMatches(job.data, revisions[f.data.processor], revisionAliases[f.data.processor]), ), ); const inAdmission = [...admitting.current.values()].filter( (item) => item.context === context && item.feature.data.generation === "section" && !jobs.some((job) => matches(job, item)), ); return preparationQueue( annotationsReady && ops.status === "ready" ? plans .filter( (item) => item.context === context && item.feature.data.enabled && revisions[item.feature.data.processor], ) .map((item) => ({ ...item, key: keyFor(item), configuration: computations[referenceOf(item.feature).object], local: item.feature.data.generation === "local", })) : [], window.visible, pending.length + inAdmission.length, submitted.current, new Set(plans.filter((item) => jobs.some((job) => matches(job, item))).map(keyFor)), ); } async function focus(range: Range, kind: "click" | "hover") { const serial = ++focusSerial.current[kind]; if (kind === "click") setClickScopes({}); const selected = features.filter( (f) => f.data.enabled && f.data.generation === "focus" && (renders[f.data.processor] ?? [{ reveal: f.data.reveal }]).some((render) => kind === "hover" ? render.reveal === "hover" : render.reveal !== "hover", ), ); // Covering the clicked word does not imply a cache hit for the newly // planned sentence/passage. Show intent now; exact scope identity decides // reuse after planning, even while operation-input admission is pending. setIntent((previous) => [ ...previous.filter((item) => item.planning !== kind), ...selected.map((feature) => ({ feature, range, context, planning: kind })), ]); if (!annotationsReady || ops.status !== "ready") return; await Promise.all( selected.map(async (feature) => { try { const ranges = await ops.value.plan.call({ feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } }, ...range, visibleStart: range.start, visibleEnd: range.end, kind: "focus", }); if (serial === focusSerial.current[kind] && currentContext.current === context) { setIntent((previous) => previous.filter( (item) => !(item.planning === kind && referenceOf(item.feature).object === referenceOf(feature).object), ), ); if (kind === "click") setClickScopes((previous) => ({ ...previous, [referenceOf(feature).object]: [...ranges] })); for (const scope of ranges) request(feature, scope); } } catch (cause) { if (serial === focusSerial.current[kind] && currentContext.current === context) { setIntent((previous) => previous.filter( (item) => !(item.planning === kind && referenceOf(item.feature).object === referenceOf(feature).object), ), ); error(errorMessage(cause)); } } }), ); } useEffect( () => () => { focusSerial.current.click++; focusSerial.current.hover++; }, [], ); return { ready: annotationsReady && ops.status === "ready" && features.filter((feature) => feature.data.enabled).every((feature) => !!revisions[feature.data.processor]), request, focus, clickScopes, pending: [ ...nearbyQueue().waiting, ...intent.filter( (item) => item.context === context && features.some( (feature) => referenceOf(feature).object === referenceOf(item.feature).object && feature.data.enabled, ) && !jobs.some((job) => matches(job, item)), ), ], cancelHover: () => { focusSerial.current.hover++; setIntent((previous) => previous.filter((item) => item.planning !== "hover")); }, }; }