import React from 'react'; import { createRoot } from 'react-dom/client'; // Automatically reload this view if certain other fragments change const changeFragments = [ '#App [data-type="text/javascript+babel"]' ]; // Start the app async function render() { if (!window.cachedAppRoot) { let element = document.createElement('transient'); element.id = 'app-root'; document.body.appendChild(element); window.cachedAppRoot = createRoot(element); } let content = await Fragment.one('#App .app').require(); window.cachedAppRoot.render(React.createElement(content.App)); }; let reloadTimer = null; const reload = () => { clearTimeout(reloadTimer); reloadTimer = setTimeout(async function reloadReact() { try { render(); } catch (ex) { console.log(ex); } }, 1000); }; changeFragments.forEach(frag => { let lookedUpFragments = Fragment.find(frag); lookedUpFragments.forEach((lookedUpFragment) => { lookedUpFragment.registerOnFragmentChangedHandler(() => { if (fragmentSelfReference.auto) { reload(); } }); }); }); reload(); import React, { createContext, useContext, useRef, useState, useCallback, useEffect, useMemo } from 'react'; import { findNodeAndOffsetAtChar, getFlatOffset, getAutotypeAnchor } from '#App [name="text-utils"]'; const EditorCtx = createContext(null); /* Record a suggestion on its anchor span (dataset powers rehydration after reloads). Deduplicates by suggestion id. */ function appendSuggestionToAnchor(anchor, suggestion) { if (!anchor || !suggestion) return; let list = []; if (anchor.dataset.autotypeSuggestions) { try { list = JSON.parse(anchor.dataset.autotypeSuggestions); if (!Array.isArray(list)) list = []; } catch (e) { list = []; } } if (!list.some((s) => s.id === suggestion.id)) { list.push(suggestion); anchor.dataset.autotypeSuggestions = JSON.stringify(list); } } /* Best-effort caret placement after an anchor was replaced by a fresh text node. normalize() may merge that node into a preceding sibling and detach it, so aiming at the node itself can throw InvalidNodeTypeError — aim at the end of the absorbing sibling instead, and never let caret trouble block the caller. */ function restoreCaretAfterReplacement(textNode, prevSibling) { try { const sel = window.getSelection(); const caretRange = document.createRange(); let placed = false; if (textNode.parentNode) { caretRange.setStartAfter(textNode); placed = true; } else if (prevSibling && prevSibling.nodeType === Node.TEXT_NODE && prevSibling.parentNode) { // normalize() merged the replacement into the preceding text // node, which now ends with the replacement text. caretRange.setStart(prevSibling, prevSibling.textContent.length); placed = true; } if (placed) { caretRange.collapse(true); sel.removeAllRanges(); sel.addRange(caretRange); } } catch (e) { // Caret restoration is cosmetic here; swallow so that the popup // cleanup in the caller always runs. } } export function useEditor() { const ctx = useContext(EditorCtx); if (!ctx) throw new Error('useEditor must be used inside <EditorProvider>'); return ctx; } export const editorContextUtils = { // utilities (e.g. getContextInfo) can be plugged here later }; export function EditorProvider({ children }) { const editorRef = useRef(null); const [menu, setMenu] = useState(null); const [actions, setActions] = useState([]); // {id} only — just for ordering const [promptModal, setPromptModal] = useState(null); // Autotype plugin system const [autotypePlugins, setAutotypePlugins] = useState([]); const [autotypeEnabled, setAutotypeEnabled] = useState(() => { try { return JSON.parse(localStorage.getItem('editor-autotype-enabled') || '{}'); } catch { return {}; } }); const [suggestions, setSuggestions] = useState([]); const pendingAutotypes = useRef(new Map()); const [activeSuggestionId, setActiveSuggestionId] = useState(null); const skipSelectionChange = useRef(false); /* ---- Rehydrate suggestions from saved DOM anchors on mount ---- */ useEffect(() => { const timer = setTimeout(() => { const root = editorRef.current; if (!root) return; const restored = []; root.querySelectorAll('.autotype-anchor').forEach(anchor => { if (!anchor.dataset.autotypeSuggestions) return; try { const list = JSON.parse(anchor.dataset.autotypeSuggestions); if (Array.isArray(list)) restored.push(...list); } catch (e) { // ignore malformed JSON } }); if (restored.length) { setSuggestions(prev => { const existingIds = new Set(prev.map(s => s.id)); const newOnes = restored.filter(s => !existingIds.has(s.id)); if (!newOnes.length) return prev; return [...prev, ...newOnes]; }); } }, 0); return () => clearTimeout(timer); }, []); // Stable storage that does NOT trigger re-renders when it changes const reg = useRef({ counts: new Map(), // id -> number (StrictMode ref counting) labels: new Map(), // id -> ref<label> handlers: new Map(), // id -> ref<handler> shouldShows: new Map(), // id -> ref<shouldShow> groupLabels: new Map(), // prefix -> ref<groupLabel> disabledFns: new Map(), // id -> ref<disabled> disabledTitles: new Map(), // id -> ref<disabledTitle> }).current; const autotypeReg = useRef({ counts: new Map(), handlers: new Map(), params: new Map(), displayNames: new Map(), }).current; const registerAction = useCallback((id, labelRef, handlerRef, shouldShowRef, groupLabelRef, disabledRef, disabledTitleRef) => { setActions(prev => { const count = (reg.counts.get(id) || 0) + 1; reg.counts.set(id, count); // StrictMode safety: only insert into list on first real mount if (prev.some(a => a.id === id)) return prev; reg.labels.set(id, labelRef); reg.handlers.set(id, handlerRef); if (shouldShowRef) reg.shouldShows.set(id, shouldShowRef); if (disabledRef) reg.disabledFns.set(id, disabledRef); if (disabledTitleRef) reg.disabledTitles.set(id, disabledTitleRef); if (groupLabelRef) { const prefix = id.substring(0, id.lastIndexOf('/')); if (prefix) { const existing = reg.groupLabels.get(prefix); if (groupLabelRef.current !== undefined || !existing) { reg.groupLabels.set(prefix, groupLabelRef); } } } return [...prev, { id }]; }); return () => { const count = (reg.counts.get(id) || 1) - 1; if (count <= 0) { reg.counts.delete(id); reg.labels.delete(id); reg.handlers.delete(id); reg.shouldShows.delete(id); reg.disabledFns.delete(id); reg.disabledTitles.delete(id); setActions(prev => prev.filter(a => a.id !== id)); } else { reg.counts.set(id, count); } }; }, [reg]); const registerAutotypePlugin = useCallback((id, handlerRef, params, displayName) => { setAutotypePlugins(prev => { const count = (autotypeReg.counts.get(id) || 0) + 1; autotypeReg.counts.set(id, count); if (prev.some(p => p.id === id)) return prev; autotypeReg.handlers.set(id, handlerRef); if (params !== undefined) autotypeReg.params.set(id, params); if (displayName !== undefined) autotypeReg.displayNames.set(id, displayName); return [...prev, { id, displayName }]; }); return () => { const count = (autotypeReg.counts.get(id) || 1) - 1; if (count <= 0) { autotypeReg.counts.delete(id); autotypeReg.handlers.delete(id); autotypeReg.params.delete(id); autotypeReg.displayNames.delete(id); setAutotypePlugins(prev => prev.filter(p => p.id !== id)); } else { autotypeReg.counts.set(id, count); } }; }, [autotypeReg]); const openMenu = useCallback((x, y, context) => setMenu({ x, y, context }), []); const closeMenu = useCallback(() => setMenu(null), []); const openPromptModal = useCallback((context, executePrompt) => setPromptModal({ isOpen: true, context, executePrompt }), []); const closePromptModal = useCallback(() => setPromptModal(null), []); const ensureAnchor = useCallback((anchorId, suggestion) => { let anchor = document.getElementById(anchorId); // Anchor already exists (several suggestions for one trigger): just // record the suggestion on it and keep its id. if (anchor) { if (suggestion) appendSuggestionToAnchor(anchor, { ...suggestion, anchorId }); return anchorId; } const pending = pendingAutotypes.current.get(anchorId); if (!pending) return null; const { paragraphElement, startOffset, endOffset, sentenceText } = pending; const currentText = paragraphElement.textContent; const expectedSlice = currentText.slice(startOffset, endOffset).trim(); const sentenceTrimmed = sentenceText.trim(); let actualStart = startOffset; let actualEnd = endOffset; if (expectedSlice !== sentenceTrimmed) { const idx = currentText.indexOf(sentenceTrimmed); if (idx === -1) { pendingAutotypes.current.delete(anchorId); return null; } actualStart = idx; actualEnd = idx + sentenceTrimmed.length; } const startInfo = findNodeAndOffsetAtChar(paragraphElement, actualStart); const endInfo = findNodeAndOffsetAtChar(paragraphElement, actualEnd); if (!startInfo || !endInfo) { pendingAutotypes.current.delete(anchorId); return null; } // The sentence is already wrapped in an anchor — a duplicate dispatch // raced past the editor's "inside an anchor" guard while the first // request was still in flight. Reuse that anchor instead of nesting a // second span around it: nested anchors leave a second suggestion // popup that lingers after the first one is accepted/rejected. const existingAnchor = getAutotypeAnchor(startInfo.node); if ( existingAnchor && existingAnchor === getAutotypeAnchor(endInfo.node) && existingAnchor.dataset.autotypeOriginal === sentenceTrimmed ) { if (suggestion) { appendSuggestionToAnchor(existingAnchor, { ...suggestion, anchorId: existingAnchor.id }); } pendingAutotypes.current.delete(anchorId); return existingAnchor.id; } const range = document.createRange(); range.setStart(startInfo.node, startInfo.offset); range.setEnd(endInfo.node, endInfo.offset); // Save current selection const sel = window.getSelection(); let savedStartFlat = null; let savedEndFlat = null; if (sel.rangeCount) { const currentRange = sel.getRangeAt(0); savedStartFlat = getFlatOffset(currentRange.startContainer, currentRange.startOffset, paragraphElement); savedEndFlat = getFlatOffset(currentRange.endContainer, currentRange.endOffset, paragraphElement); } const span = document.createElement('span'); span.className = 'autotype-anchor'; span.id = anchorId; span.dataset.autotypeId = anchorId; span.dataset.autotypeOriginal = sentenceTrimmed; if (suggestion) { appendSuggestionToAnchor(span, { ...suggestion, anchorId }); } span.appendChild(range.extractContents()); range.insertNode(span); span.parentNode?.normalize(); // Restore selection to not disrupt caret if (savedStartFlat !== null) { const startInfo = findNodeAndOffsetAtChar(paragraphElement, savedStartFlat); const endInfo = findNodeAndOffsetAtChar(paragraphElement, savedEndFlat); if (startInfo && endInfo) { skipSelectionChange.current = true; const newRange = document.createRange(); newRange.setStart(startInfo.node, startInfo.offset); newRange.setEnd(endInfo.node, endInfo.offset); sel.removeAllRanges(); sel.addRange(newRange); setTimeout(() => { skipSelectionChange.current = false; }, 0); } } pendingAutotypes.current.delete(anchorId); return anchorId; }, []); const dispatchSentenceCompletion = useCallback((sentenceText, anchorId, paragraphElement, startOffset, endOffset, trigger = 'auto') => { // Supersede any still-pending completion for the very same sentence // (same paragraph and character range). A sentence can be re-triggered // while its first request is in flight — typing ". " followed by Enter // completes the same sentence twice, and so does a Ctrl+Space retry. // The stale response would otherwise wrap a second, nested anchor // around the first, whose popup lingers after the other is accepted. const text = (sentenceText || '').trim(); for (const [id, pending] of Array.from(pendingAutotypes.current)) { if ( pending.paragraphElement === paragraphElement && (pending.sentenceText || '').trim() === text && pending.startOffset === startOffset && pending.endOffset === endOffset ) { pendingAutotypes.current.delete(id); } } pendingAutotypes.current.set(anchorId, { sentenceText, paragraphElement, startOffset, endOffset, }); for (const p of autotypePlugins) { if (autotypeEnabled[p.id] === false) continue; const handler = autotypeReg.handlers.get(p.id)?.current; if (handler) { handler({ sentenceText, anchorId, paragraphElement, trigger }); } } }, [autotypePlugins, autotypeReg, autotypeEnabled]); const addSuggestion = useCallback((suggestion) => { if (suggestion.originalText?.trim() === suggestion.suggestedText?.trim()) return; // ensureAnchor creates — or reuses — the anchor span and returns the id // the suggestion is actually bound to (a duplicate dispatch may have // been folded into an already existing anchor). const anchorId = ensureAnchor(suggestion.anchorId, suggestion); if (!anchorId) return; const s = { ...suggestion, anchorId }; setSuggestions(prev => { if (prev.some(p => p.id === s.id)) return prev; return [...prev, s]; }); }, [ensureAnchor]); const removeSuggestion = useCallback((id) => { document.querySelectorAll('.autotype-anchor').forEach(anchor => { if (anchor.dataset.autotypeSuggestions) { try { const existing = JSON.parse(anchor.dataset.autotypeSuggestions); const idx = existing.findIndex(s => s.id === id); if (idx !== -1) { const updated = existing.filter(s => s.id !== id); if (updated.length > 0) { anchor.dataset.autotypeSuggestions = JSON.stringify(updated); } else { delete anchor.dataset.autotypeSuggestions; } } } catch (e) { // ignore } } }); setSuggestions(prev => prev.filter(s => s.id !== id)); }, []); const acceptSuggestion = useCallback((id) => { const s = suggestions.find(su => su.id === id); if (!s) return; const anchor = document.getElementById(s.anchorId); if (!anchor) return; const textNode = document.createTextNode(s.suggestedText); const prevSibling = anchor.previousSibling; skipSelectionChange.current = true; anchor.replaceWith(textNode); textNode.parentNode?.normalize(); restoreCaretAfterReplacement(textNode, prevSibling); setTimeout(() => { skipSelectionChange.current = false; }, 0); // Remove every suggestion bound to this anchor (a retried sentence can // stack several on one span) and always hide the popup. setSuggestions(prev => prev.filter(su => su.anchorId !== s.anchorId)); setActiveSuggestionId(null); }, [suggestions]); const rejectSuggestion = useCallback((id) => { const s = suggestions.find(su => su.id === id); if (!s) return; const anchor = document.getElementById(s.anchorId); if (!anchor) return; const textNode = document.createTextNode(s.originalText); const prevSibling = anchor.previousSibling; skipSelectionChange.current = true; anchor.replaceWith(textNode); textNode.parentNode?.normalize(); restoreCaretAfterReplacement(textNode, prevSibling); setTimeout(() => { skipSelectionChange.current = false; }, 0); // Same as accept: drop all suggestions of this anchor and hide the popup. setSuggestions(prev => prev.filter(su => su.anchorId !== s.anchorId)); setActiveSuggestionId(null); }, [suggestions]); const showSuggestion = useCallback((anchorId) => { setActiveSuggestionId(anchorId); }, []); const hideSuggestion = useCallback(() => { setActiveSuggestionId(null); }, []); const getLabel = useCallback((id) => reg.labels.get(id)?.current, [reg]); const getHandler = useCallback((id) => reg.handlers.get(id)?.current, [reg]); const getShouldShow = useCallback((id) => reg.shouldShows.get(id)?.current, [reg]); const getDisabled = useCallback((id) => reg.disabledFns.get(id)?.current, [reg]); const getDisabledTitle = useCallback((id) => reg.disabledTitles.get(id)?.current, [reg]); const getGroupLabel = useCallback((prefix) => reg.groupLabels.get(prefix)?.current, [reg]); const getAutotypeParams = useCallback((id) => autotypeReg.params.get(id), [autotypeReg]); const isAutotypeEnabled = useCallback((id) => { return autotypeEnabled[id] !== false; }, [autotypeEnabled]); const setAutotypeEnabledForId = useCallback((id, enabled) => { setAutotypeEnabled(prev => { const next = { ...prev, [id]: enabled }; try { localStorage.setItem('editor-autotype-enabled', JSON.stringify(next)); } catch { // ignore } return next; }); }, []); const getAutotypeSettings = useCallback((id) => { const params = autotypeReg.params.get(id); if (!params) return {}; const key = `editor-autotype-params-${id}`; let saved = {}; try { saved = JSON.parse(localStorage.getItem(key) || '{}'); } catch { saved = {}; } const settings = {}; for (const [k, spec] of Object.entries(params)) { settings[k] = saved[k] !== undefined ? saved[k] : spec.default; } return settings; }, [autotypeReg]); const ctxValue = useMemo(() => ({ editorRef, registerAction, openMenu, closeMenu, menu, actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle, promptModal, openPromptModal, closePromptModal, registerAutotypePlugin, getAutotypeParams, getAutotypeSettings, dispatchSentenceCompletion, suggestions, addSuggestion, removeSuggestion, acceptSuggestion, rejectSuggestion, activeSuggestionId, showSuggestion, hideSuggestion, skipSelectionChange, autotypePlugins, isAutotypeEnabled, setAutotypeEnabledForId, }), [ editorRef, registerAction, openMenu, closeMenu, menu, actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle, promptModal, openPromptModal, closePromptModal, registerAutotypePlugin, getAutotypeParams, getAutotypeSettings, dispatchSentenceCompletion, suggestions, addSuggestion, removeSuggestion, acceptSuggestion, rejectSuggestion, activeSuggestionId, showSuggestion, hideSuggestion, skipSelectionChange, autotypePlugins, autotypeEnabled, isAutotypeEnabled, setAutotypeEnabledForId, ]); return ( <EditorCtx.Provider value={ctxValue}> {children} </EditorCtx.Provider> ); } export function useContextMenuItem(id, label, handler, shouldShow, groupLabel, disabled, disabledTitle) { const { registerAction } = useEditor(); // These refs are reassigned every render so they stay fresh, // but they NEVER trigger the useEffect below. const labelRef = useRef(label); const handlerRef = useRef(handler); const shouldShowRef = useRef(shouldShow); const groupLabelRef = useRef(groupLabel); const disabledRef = useRef(disabled); const disabledTitleRef = useRef(disabledTitle); labelRef.current = label; handlerRef.current = handler; shouldShowRef.current = shouldShow; groupLabelRef.current = groupLabel; disabledRef.current = disabled; disabledTitleRef.current = disabledTitle; useEffect(() => { return registerAction(id, labelRef, handlerRef, shouldShowRef, groupLabelRef, disabledRef, disabledTitleRef); // Only the stable ID (and the stable registerAction) matter now. // Inline arrow functions in your plugins are safe. }, [id, registerAction]); } export function useAutotypePlugin(id, handler, params, displayName) { const { registerAutotypePlugin } = useEditor(); const handlerRef = useRef(handler); const paramsRef = useRef(params); handlerRef.current = handler; paramsRef.current = params; useEffect(() => { return registerAutotypePlugin(id, handlerRef, paramsRef.current, displayName); }, [id, registerAutotypePlugin, displayName]); } import React from 'react'; import { createRoot } from 'react-dom/client'; import { EditorProvider, useEditor, useContextMenuItem, useAutotypePlugin, } from '#App [name="editor-context"]'; import { TextEditor } from '#App [name="TextEditor"]'; import { PromptModal } from '#App [name="PromptModal"]'; import { DLLMReimaginePlugin } from '#App [name="contextmenu-dllm-reimagine"]'; import { SentenceLoggerPlugin, HighlightParagraphPlugin, } from '#App [name="contextmenu-debug"]'; import { AutotypeDebugPlugin, AutotypeLowercasePlugin, } from '#App [name="autotype-debug"]'; import { AutotypeLLMPlugin } from '#App [name="autotype-llm"]'; import { SentenceSuggestPopup } from '#App [name="SentenceSuggestPopup"]'; import { Sidebar, SetupPanel, AutotypePluginSetupPanels } from '#App [name="Sidebar"]'; function createMDLMParams() { return { temp: { type: 'range', min: 0, max: 2, step: 0.1, default: 0.7, hint: 'Sampling temperature', }, steps: { type: 'range', min: 1, max: 196, step: 1, default: 128, hint: 'Number of diffusion steps', }, remasking: { type: 'enum', values: [ { value: 'low_confidence', title: 'Low Confidence', hint: 'Remask low confidence tokens' }, { value: 'random', title: 'Random', hint: 'Random remasking' }, ], default: 'low_confidence', hint: 'Remasking strategy', }, blocksize: { type: 'range', min: 1, max: 128, step: 1, default: 32, hint: 'Block size for parallel decoding', }, cfg_scale: { name: 'Guidance', type: 'range', min: 0, max: 5, step: 0.1, default: 0, hint: 'Guidance (cfg_scale): classifier-free guidance strength: blends the model\u2019s prompt-conditioned logits with an unconditioned pass. 0 = off; higher values sharpen adherence to the prompt at the cost of diversity. Needs un-training-free CFG; only supported by models run through MDLM sampling.', }, stochastic_transfer: { name: 'Re-roll tokens', type: 'enum', values: [ { value: 'false', title: 'Off', hint: 'Frozen after first commit (default)' }, { value: 'true', title: 'On', hint: 'Committed tokens can still change in later steps' }, ], default: 'false', hint: 'Re-roll tokens (stochastic_transfer): whether already-committed tokens are re-sampled (stochastically transferred) in later denoising steps instead of being frozen at their first argmax commit. Off = more stable, cheaper; On = can fix earlier mistakes but output can keep wobbling.', }, seed: { type: 'range', min: 0, max: 999999, step: 1, default: 42, hint: 'Random seed', }, }; } /* DiffusionGemma (block-diffusion, encoder-decoder; knobs accepted by the dllm-fastapi server and forwarded into DiffusionGemmaSamplerConfig). Ranges/hints follow the sampler code and the diffusiongemma examples. */ function createDiffusionGemmaParams() { return { steps: { type: 'range', min: 8, max: 128, step: 1, default: 48, hint: 'Max denoising steps per 256-token canvas block. Quality rises with steps, latency rises too. Adaptive stopping usually ends early, so this is a budget, not a fixed cost (evals use 48).', }, entropy_bound: { name: 'Write speed', type: 'range', min: 0, max: 1, step: 0.01, default: 0.1, hint: 'Write speed (entropy_bound): per-step token budget (in nats of entropy) deciding how many canvas tokens are accepted per denoising step: tokens are sorted by entropy (most confident first) and accepted until the cumulative entropy budget is spent. Higher = more tokens accepted per step = faster but noisier; lower = stricter, more careful. 0.1 for evals, down to ~0.001 for strict infill.', }, entropy_threshold: { name: 'Stop threshold', type: 'range', min: 0, max: 0.5, step: 0.001, default: 0.005, hint: 'Stop threshold (entropy_threshold): early-stop confidence threshold: mean token entropy (in nats) below this marks the canvas as converged and stops denoising early. Lower = later stop, more refinement; higher = stop sooner, faster.', }, stability_threshold: { name: 'Stop patience', type: 'range', min: 0, max: 10, step: 1, default: 1, hint: 'Stop patience (stability_threshold): number of consecutive steps the most-confident (argmax) canvas must stay unchanged (AND below entropy_threshold) before early stopping. 0 = stability check disabled (only entropy threshold applies), 1 = default.', }, pin_anchor_alpha: { name: 'Anchor strength', type: 'range', min: 0, max: 1, step: 0.05, default: 1, hint: 'Anchor strength (pin_anchor_alpha): infill only. 1.0 = hard pins: your text always survives verbatim and the model plans the whole gap around it. Below 1.0 = soft pins: your text stays visible to the model through every step, but the model may rewrite it at the end if it confidently disagrees — e.g. fix a pinned "She" to "He" once "his head" has been diffused around it. Lower values = more model trust. Plain generation ignores this.', }, max_tokens: { name: 'Max tokens', type: 'range', min: 256, max: 1024, step: 256, default: 256, hint: 'Max tokens (max_tokens): generation budget in tokens, rounded up to full 256-token canvas blocks (256 = one block, 512 = two, ...). The model stops at the end-of-turn token, so this is a cap, not a target. Infill only pads this to the suggestion span (the turn boundary is pinned there anyway).', }, max_temperature: { name: 'Temp start', type: 'range', min: 0.05, max: 2, step: 0.05, default: 0.8, hint: 'Temp start (max_temperature): sampling temperature at the first denoising step. DiffusionGemma anneals linearly from this value down to Temp end across the steps: higher start = more exploration/diversity early on; set start = end for a constant temperature. Near 0 ≈ greedy.', }, min_temperature: { name: 'Temp end', type: 'range', min: 0.05, max: 2, step: 0.05, default: 0.4, hint: 'Temp end (min_temperature): sampling temperature at the last denoising step. DiffusionGemma anneals linearly from Temp start down to this value: lower end = sharper, more deterministic convergence. Near 0 ≈ greedy.', }, seed: { type: 'range', min: 0, max: 999999, step: 1, default: 42, hint: 'Random seed', }, }; } window.availableModels = [ { name: "haic/llada2.1-mini-pruned128", params: createMDLMParams() }, { name: "haic/qwen3-0.6b-diffusion-mdlm-v0.1", params: createMDLMParams() }, { name: "nugget/diffusiongemma-26B-A4B-it", params: createDiffusionGemmaParams() }, ]; /* Per-model request bodies are built by buildModelRequestBody() in #App [name="dllm-utils"] (import it where needed). */ function loadActiveModel() { if (typeof localStorage !== 'undefined') { const savedName = localStorage.getItem('editor-active-model-name'); if (savedName) { const found = window.availableModels.find(m => m.name === savedName); if (found) return found; } } return window.availableModels[0]; } window.activeModel = loadActiveModel(); export function App() { return ( <EditorProvider> <div className="app-layout"> <TextEditor useEditor={useEditor} /> <Sidebar> <SetupPanel /> <AutotypePluginSetupPanels useEditor={useEditor} /> </Sidebar> </div> <DLLMReimaginePlugin useEditor={useEditor} useContextMenuItem={useContextMenuItem} /> <SentenceLoggerPlugin useContextMenuItem={useContextMenuItem} /> <HighlightParagraphPlugin useContextMenuItem={useContextMenuItem} /> <AutotypeLLMPlugin useEditor={useEditor} useAutotypePlugin={useAutotypePlugin} /> <SentenceSuggestPopup useEditor={useEditor} /> <PromptModal useEditor={useEditor} /> <div className="prototype-overlay"> Prototype! <div className="prototype-overlay__sub">Use at own risk</div> </div> </EditorProvider> ); } .toolbar { position: sticky; top: 0; z-index: 1; background: #f3f3f3; border-bottom: 1px solid #ccc; padding: 8px 16px; display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 4px; font-family: system-ui, sans-serif; &__group { display: inline-flex; align-items: center; margin-right: 4px; } &__separator { display: inline-block; width: 1px; height: 24px; background: #ccc; margin: 0 6px; vertical-align: middle; } &__btn { min-width: 32px; height: 32px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; font-size: 14px; display: inline-flex; align-items: center; justify-content: center; margin-right: 4px; color: #333; &--active { background: #d0d0d0; } .material-icons-outlined { font-size: 20px; } &--small { font-size: 13px; } &--bold { font-weight: 700; } &--italic { font-style: italic; } &--underline { text-decoration: underline; } } } import React, { useState, useEffect } from 'react'; function exec(cmd, val = null) { document.execCommand(cmd, false, val); } function ToolbarButton({ cmd, label, variant, active, title }) { const className = [ 'toolbar__btn', active && 'toolbar__btn--active', variant && `toolbar__btn--${variant}`, ].filter(Boolean).join(' '); return ( <button type="button" className={className} onMouseDown={(e) => { e.preventDefault(); exec(cmd); }} title={title} > {label} </button> ); } function ToolbarSeparator() { return <span className="toolbar__separator" />; } function BlockButton({ tag, label, title }) { const [tick, setTick] = useState(0); useEffect(() => { const onSel = () => setTick(t => t + 1); document.addEventListener('selectionchange', onSel); return () => document.removeEventListener('selectionchange', onSel); }, []); const blockValue = () => { try { return (document.queryCommandValue('formatBlock') || '').toString().toLowerCase(); } catch { return ''; } }; const active = blockValue() === tag; return ( <button type="button" className={[ 'toolbar__btn', active && 'toolbar__btn--active', ].filter(Boolean).join(' ')} onMouseDown={(e) => { e.preventDefault(); exec('formatBlock', tag); }} title={title} > {label} </button> ); } export function FormattingToolbar({ surfaceRef }) { const [tick, setTick] = useState(0); useEffect(() => { const onSel = () => setTick(t => t + 1); document.addEventListener('selectionchange', onSel); return () => document.removeEventListener('selectionchange', onSel); }, []); useEffect(() => { const el = surfaceRef.current; if (!el) return; const onUp = () => setTick(t => t + 1); el.addEventListener('keyup', onUp); el.addEventListener('mouseup', onUp); return () => { el.removeEventListener('keyup', onUp); el.removeEventListener('mouseup', onUp); }; }, [surfaceRef]); const isActive = (cmd) => { try { return document.queryCommandState(cmd); } catch { return false; } }; const Group = ({ children }) => ( <div className="toolbar__group"> {children} </div> ); return ( <div className="toolbar"> <Group> <ToolbarButton cmd="undo" title="Undo" label={<span unapproved="" className="material-icons-outlined">undo</span>} /> <ToolbarButton cmd="redo" title="Redo" label={<span unapproved="" className="material-icons-outlined">redo</span>} /> </Group> <ToolbarSeparator /> <Group> <ToolbarButton cmd="bold" title="Bold" label={<span unapproved="" className="material-icons-outlined">format_bold</span>} active={isActive('bold')} /> <ToolbarButton cmd="italic" title="Italic" label={<span unapproved="" className="material-icons-outlined">format_italic</span>} active={isActive('italic')} /> <ToolbarButton cmd="underline" title="Underline" label={<span unapproved="" className="material-icons-outlined">format_underlined</span>} active={isActive('underline')} /> <ToolbarButton cmd="strikeThrough" title="Strikethrough" label={<span unapproved="" className="material-icons-outlined">strikethrough_s</span>} active={isActive('strikeThrough')} /> </Group> <ToolbarSeparator /> <Group> <BlockButton tag="h1" title="Heading 1" label={<span unapproved="" className="material-icons-outlined">looks_one</span>} /> <BlockButton tag="h2" title="Heading 2" label={<span unapproved="" className="material-icons-outlined">looks_two</span>} /> <BlockButton tag="p" title="Paragraph" label={<span unapproved="" className="material-icons-outlined">text_fields</span>} /> </Group> <ToolbarSeparator /> <Group> <ToolbarButton cmd="justifyLeft" title="Align left" label={<span unapproved="" className="material-icons-outlined">format_align_left</span>} active={isActive('justifyLeft')} /> <ToolbarButton cmd="justifyCenter" title="Align center" label={<span unapproved="" className="material-icons-outlined">format_align_center</span>} active={isActive('justifyCenter')} /> <ToolbarButton cmd="justifyRight" title="Align right" label={<span unapproved="" className="material-icons-outlined">format_align_right</span>} active={isActive('justifyRight')} /> </Group> <ToolbarSeparator /> <Group> <ToolbarButton cmd="insertUnorderedList" title="Bulleted list" label={<span unapproved="" className="material-icons-outlined">format_list_bulleted</span>} active={isActive('insertUnorderedList')} /> <ToolbarButton cmd="insertOrderedList" title="Numbered list" label={<span unapproved="" className="material-icons-outlined">format_list_numbered</span>} active={isActive('insertOrderedList')} /> </Group> </div> ); } import React, { useEffect } from 'react'; import * as utils from '#App [name="text-utils"]'; /* ============================================================ ContextMenu helpers ============================================================ */ function buildMenuTree(actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle, context) { const root = { items: [] }; for (const a of actions) { const shouldShow = getShouldShow(a.id); if (shouldShow && !shouldShow(context)) continue; const parts = a.id.split('/'); let current = root; let parentGroupNode = null; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLeaf = i === parts.length - 1; let node = current.items.find(n => n.part === part); if (!node) { node = { part, items: [] }; current.items.push(node); } if (isLeaf) { node.id = a.id; node.label = getLabel(a.id); node.handler = getHandler(a.id); const disabledFn = getDisabled ? getDisabled(a.id) : undefined; node.disabled = typeof disabledFn === 'function' ? disabledFn(context) : !!disabledFn; node.disabledTitle = getDisabledTitle ? getDisabledTitle(a.id) : undefined; // look up custom group label and attach to the parent group node if (parts.length > 1 && parentGroupNode) { const prefix = a.id.substring(0, a.id.lastIndexOf('/')); const groupLabel = getGroupLabel(prefix); if (groupLabel) { parentGroupNode.groupLabel = groupLabel; } } } parentGroupNode = node; current = node; } } function prune(nodes) { const out = []; for (const n of nodes) { if (!n.id) { // group node — prune children n.items = prune(n.items); if (n.items.length === 0) continue; } out.push(n); } return out; } return prune(root.items); } function titleCase(str) { return str.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } function MenuLevel({ nodes, context, closeMenu, depth = 0 }) { if (nodes.length === 0) return null; const children = nodes.map(node => { if (node.items && node.items.length > 0) { return ( <div key={node.part} className="context-menu__item context-menu__item--group"> <span>{node.label || node.groupLabel || titleCase(node.part)}</span> <span className="context-menu__chevron">›</span> <MenuLevel nodes={node.items} context={context} closeMenu={closeMenu} depth={depth + 1} /> </div> ); } if (node.disabled) { return ( <div key={node.id} className="context-menu__item context-menu__item--disabled" title={node.disabledTitle ?? ''} > {node.label} </div> ); } return ( <div key={node.id} className="context-menu__item" onClick={() => { node.handler?.(context); closeMenu(); }} > {node.label} </div> ); }); if (depth === 0) return <>{children}</>; return <div className="context-menu__submenu">{children}</div>; } /* ============================================================ ContextMenu ============================================================ */ export function ContextMenu({ useEditor }) { const { menu, closeMenu, actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle } = useEditor(); /* Defensive: old provider builds may not expose these getters yet. */ const safeGetDisabled = getDisabled || (() => undefined); const safeGetDisabledTitle = getDisabledTitle || (() => undefined); useEffect(() => { if (!menu) return; const onClick = () => closeMenu(); const onScroll = () => closeMenu(); const onResize = () => closeMenu(); const onKeyDown = (e) => { if (e.key === 'Escape') closeMenu(); }; window.addEventListener('click', onClick); window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onResize); window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('click', onClick); window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize); window.removeEventListener('keydown', onKeyDown); }; }, [menu, closeMenu]); if (!menu) return null; const { x, y, context } = menu; const tree = buildMenuTree(actions, getLabel, getHandler, getShouldShow, getGroupLabel, safeGetDisabled, safeGetDisabledTitle, context); if (tree.length === 0) return null; return ( <div className="context-menu" style={{ left: Math.min(x + 8, window.innerWidth - 220), top: Math.min(y + 8, window.innerHeight - tree.length * 36), }} onClick={e => e.stopPropagation()} onMouseDown={e => e.preventDefault()} > <MenuLevel nodes={tree} context={context} closeMenu={closeMenu} depth={0} /> </div> ); } .context-menu { position: fixed; background: #fff; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); padding: 4px 0; min-width: 200px; z-index: 10000; &__item { padding: 10px 16px; cursor: pointer; font-size: 14px; color: #222; border-bottom: 1px solid #f0f0f0; &:last-child { border-bottom: none; } &:hover { background: #f5f5f5; } &--disabled { color: #888; cursor: not-allowed; background: transparent; &:hover { background: transparent; } } &--group { position: relative; display: flex; align-items: center; justify-content: space-between; padding-right: 12px; &:hover > .context-menu__submenu { display: block; } } } &__submenu { position: absolute; top: -4px; left: calc(100% - 4px); background: #fff; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); padding: 4px 0; min-width: 200px; display: none; z-index: 10001; /* Invisible hover bridge so the cursor can move from parent item into submenu without traversing a dead-zone gap. */ &::before { content: ''; position: absolute; top: 0; left: -8px; width: 8px; height: 100%; } } &__chevron { font-size: 16px; color: #888; margin-left: 8px; } } import React, { useRef, useEffect } from 'react'; import * as utils from '#App [name="text-utils"]'; import { FormattingToolbar } from '#App [name="FormattingToolbar"]'; import { ContextMenu } from '#App [name="ContextMenu"]'; /* ============================================================ TextEditor ============================================================ */ export function TextEditor({ useEditor, defaultValue }) { const { editorRef, openMenu, closeMenu, dispatchSentenceCompletion, showSuggestion, hideSuggestion, skipSelectionChange, removeSuggestion, suggestions } = useEditor(); const forcedAnchorIds = useRef(new Set()); const surfaceRef = useRef(null); const initialized = useRef(false); const saveTimer = useRef(null); useEffect(() => { if (surfaceRef.current && !initialized.current) { const saved = localStorage.getItem('wpm-document'); surfaceRef.current.innerHTML = saved ?? defaultValue ?? `<h1>Untitled Document</h1> <p>Start writing here. Use the toolbar above to format your text.</p>`; initialized.current = true; surfaceRef.current.focus(); } }, [defaultValue]); /* ---- Debounced save to localStorage ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const onInput = () => { clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { localStorage.setItem('wpm-document', el.innerHTML); }, 3000); }; el.addEventListener('input', onInput); return () => { el.removeEventListener('input', onInput); clearTimeout(saveTimer.current); }; }, []); /* ---- Track caret inside autotype anchors ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleSelectionChange = () => { if (skipSelectionChange.current) return; const sel = window.getSelection(); if (!sel.rangeCount) { hideSuggestion(); return; } const range = sel.getRangeAt(0); if (!el.contains(range.commonAncestorContainer)) { hideSuggestion(); return; } const node = range.startContainer; const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; const anchor = element?.closest?.('.autotype-anchor'); if (anchor) { showSuggestion(anchor.id); } else { hideSuggestion(); } }; document.addEventListener('selectionchange', handleSelectionChange); return () => document.removeEventListener('selectionchange', handleSelectionChange); }, [showSuggestion, hideSuggestion]); /* ---- Auto-show popup for forced completions ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const pending = Array.from(forcedAnchorIds.current); for (const id of pending) { const anchor = document.getElementById(id); if (!anchor || !el.contains(anchor)) { forcedAnchorIds.current.delete(id); continue; } showSuggestion(anchor.id); forcedAnchorIds.current.delete(id); } }, [suggestions, showSuggestion]); /* ---- Unwrap autotype anchor on direct typing ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleBeforeInput = (e) => { if (!e.inputType || !e.inputType.startsWith('insert')) return; const sel = window.getSelection(); if (!sel.rangeCount) return; const range = sel.getRangeAt(0); const node = range.startContainer; const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; const anchor = element?.closest?.('.autotype-anchor'); if (!anchor) return; const idsToRemove = utils.unwrapAutotypeAnchor(anchor); idsToRemove.forEach(id => removeSuggestion(id)); }; el.addEventListener('beforeinput', handleBeforeInput); return () => el.removeEventListener('beforeinput', handleBeforeInput); }, [removeSuggestion]); /* ---- Sentence completion trigger ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleInput = (e) => { if (e.inputType !== 'insertText' && e.inputType !== 'insertParagraph' && e.inputType !== 'insertLineBreak') { return; } const sel = window.getSelection(); if (!sel.rangeCount) { return; } const range = sel.getRangeAt(0); let sentence = null; if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') { sentence = utils.getLastCompletedSentence(range, el, true); } else if (e.inputType === 'insertText' && e.data === ' ') { const block = utils.getParagraphElement(range.endContainer, el); if (!block) { return; } const caretFlat = utils.getFlatOffset(range.endContainer, range.endOffset, block); const trimmedLen = block.textContent.trimEnd().length; if (caretFlat < trimmedLen) { return; } if (caretFlat >= 2) { const precedingChar = block.textContent[caretFlat - 2]; if ('.!?'.includes(precedingChar)) { sentence = utils.getLastCompletedSentence(range, el, false); } } } if (!sentence) { return; } if (utils.isInsideAutotypeAnchor(sentence.range.startContainer)) { return; } const anchorId = `autotype-${Date.now()}-${Math.random().toString(36).slice(2)}`; const startOffset = utils.getFlatOffset(sentence.range.startContainer, sentence.range.startOffset, sentence.container); const endOffset = utils.getFlatOffset(sentence.range.endContainer, sentence.range.endOffset, sentence.container); setTimeout(() => { dispatchSentenceCompletion(sentence.text, anchorId, sentence.container, startOffset, endOffset, 'auto'); }, 0); }; el.addEventListener('input', handleInput); return () => el.removeEventListener('input', handleInput); }, [dispatchSentenceCompletion]); /* ---- Manual sentence completion trigger ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleKeyDown = (e) => { const isTrigger = (e.ctrlKey || e.metaKey) && e.code === 'Space'; if (!isTrigger) return; e.preventDefault(); const sel = window.getSelection(); if (!sel.rangeCount) return; const range = sel.getRangeAt(0); if (utils.isInsideAutotypeAnchor(range.startContainer)) return; const sentence = utils.getSentenceAtCaret(range, el); if (!sentence || utils.isInsideAutotypeAnchor(sentence.range.startContainer)) return; const anchorId = `autotype-${Date.now()}-${Math.random().toString(36).slice(2)}`; const startOffset = utils.getFlatOffset(sentence.range.startContainer, sentence.range.startOffset, sentence.container); const endOffset = utils.getFlatOffset(sentence.range.endContainer, sentence.range.endOffset, sentence.container); forcedAnchorIds.current.add(anchorId); dispatchSentenceCompletion(sentence.text, anchorId, sentence.container, startOffset, endOffset, 'manual'); }; el.addEventListener('keydown', handleKeyDown); return () => el.removeEventListener('keydown', handleKeyDown); }, [dispatchSentenceCompletion]); const handleContextMenu = (e) => { e.preventDefault(); const sel = window.getSelection(); let range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; if (!range || range.collapsed) { const caret = utils.getCaretRangeFromPoint(e.clientX, e.clientY); if (caret) { range = utils.selectWordAt(caret); sel.removeAllRanges(); sel.addRange(range); } } if (!range) return; const root = surfaceRef.current; const ctxInfo = utils.getContextInfo(range, root); if (!ctxInfo) return; const context = { text: ctxInfo.text, selectionStart: ctxInfo.selectionStart, selectionEnd: ctxInfo.selectionEnd, containerElement: ctxInfo.containerElement, // Kept so the prompt modal can re-derive the context at a different // scope (sentence / sibling sentences / paragraph / …) on the fly. _sourceRange: range.cloneRange(), _root: root, replace: (newText) => { const idsToRemove = utils.replaceContextRange(ctxInfo, newText); idsToRemove.forEach(id => removeSuggestion(id)); }, }; openMenu(e.clientX, e.clientY, context); }; return ( <div className="editor-page"> <FormattingToolbar surfaceRef={surfaceRef} /> <div className="editor-container"> <div ref={(node) => { surfaceRef.current = node; editorRef.current = node; }} className="editor-surface" contentEditable suppressContentEditableWarning onContextMenu={handleContextMenu} onClick={closeMenu} /> </div> <ContextMenu useEditor={useEditor} /> </div> ); } .editor-page { background: #e4e4e4; flex: 1; min-height: 0; overflow-y: auto; } .editor-container { padding: 2rem 1rem; } .editor-surface { max-width: 210mm; min-height: 297mm; margin: 0 auto; padding: 25mm 20mm; background: #fff; box-shadow: 0 0 12px rgba(0, 0, 0, 0.15); border-radius: 2px; line-height: 1.6; font-family: Georgia, "Times New Roman", serif; font-size: 16px; color: #222; outline: none; /* Persisted selection highlight when context-menu is open */ mark.editor-selection-persist { background: #b4d7ff; color: inherit; } } import React, { useState, useEffect, useRef } from 'react'; import { getInitialValues } from '#App [name="Sidebar"]'; import { getContextInfo, replaceContextRange } from '#App [name="text-utils"]'; /* ------------------------------------------------------------ Diff helpers ------------------------------------------------------------ */ function escapeHtml(str) { return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } /* ------------------------------------------------------------ Settings builder ------------------------------------------------------------ */ function buildInitialSettings(params) { const s = {}; for (const [k, v] of Object.entries(params)) { s[k] = v.default !== undefined ? v.default : (v.type === 'range' ? (v.min || 0) : (v.type === 'enum' ? (v.values?.[0]?.value || '') : (v.type === 'checkbox' ? !!v.default : ''))); } return s; } function trimInvisible(str) { return str.replace(/^[\s\p{Cc}\p{Cf}]+|[\s\p{Cc}\p{Cf}]+$/gu, ''); } /* ------------------------------------------------------------ ConfigParam ------------------------------------------------------------ */ function ConfigParam({ name, config, value, onChange }) { const { type, hint, name: configName } = config || {}; const displayName = configName || name; if (type === 'range') { return ( <div className="config-param"> <label className="config-param__label"> {displayName} <span className="config-param__value">{value}</span> </label> <input type="range" min={config.min} max={config.max} step={config.step} value={value} title={hint || ''} onChange={(e) => onChange(parseFloat(e.target.value))} /> </div> ); } if (type === 'enum' || type === 'context-scope') { return ( <div className="config-param"> <label className="config-param__label">{displayName}</label> <select value={value} title={hint || ''} onChange={(e) => onChange(e.target.value)}> {config.values.map((v) => ( <option key={v.value} value={v.value} title={v.hint}> {v.title || v.value} </option> ))} </select> </div> ); } if (type === 'checkbox') { return ( <div className="config-param"> <label className="config-param__label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }} title={hint || ''}> <input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} /> <span>{displayName}</span> </label> </div> ); } if (type === 'text') { return ( <div className="config-param"> <label className="config-param__label">{displayName}</label> <textarea value={value} title={hint || ''} onChange={(e) => onChange(e.target.value)} rows={4} /> </div> ); } return null; } /* ------------------------------------------------------------ PromptModal ------------------------------------------------------------ */ export function PromptModal({ useEditor }) { const { promptModal, closePromptModal, removeSuggestion } = useEditor(); const isOpen = promptModal?.isOpen || false; const modalContext = promptModal?.context || {}; const pluginParams = modalContext.pluginParams || {}; const executePrompt = promptModal?.executePrompt; const activeModel = window.activeModel || { params: {} }; const modelParams = activeModel.params || {}; /* -- All hooks before any conditional return -- */ const [modelSettings, setModelSettings] = useState(() => getInitialValues(modelParams, activeModel.name)); const [pluginSettings, setPluginSettings] = useState(() => buildInitialSettings(pluginParams)); const [outputText, setOutputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const promptDelayRef = useRef(100); // Ensure settings always contain valid defaults so inputs are never uncontrolled const effectiveModelSettings = React.useMemo( () => ({ ...getInitialValues(modelParams, activeModel.name), ...modelSettings }), [modelParams, activeModel.name, modelSettings] ); const effectivePluginSettings = React.useMemo( () => ({ ...buildInitialSettings(pluginParams), ...pluginSettings }), [pluginParams, pluginSettings] ); // A plugin param of type 'context-scope' lets the user choose how far // around the selection the context extends (sentence, sibling sentences, // paragraph, sibling paragraphs, document). When one is present, re-derive // the text context from the original selection at the chosen scope; the // plugin then transparently operates on that larger context. const scopeParamKey = Object.keys(pluginParams).find( (k) => pluginParams[k] && pluginParams[k].type === 'context-scope' ) || null; const scopeValue = scopeParamKey ? effectivePluginSettings[scopeParamKey] : null; const scopedContext = React.useMemo(() => { if (!scopeValue || !modalContext._sourceRange || !modalContext._root) return modalContext; try { const info = getContextInfo(modalContext._sourceRange, modalContext._root, scopeValue); if (!info) return modalContext; return { ...modalContext, text: info.text, selectionStart: info.selectionStart, selectionEnd: info.selectionEnd, replace: (newText) => { const idsToRemove = replaceContextRange(info, newText); idsToRemove.forEach((id) => removeSuggestion(id)); }, }; } catch (e) { return modalContext; } }, [modalContext, scopeValue, removeSuggestion]); // Reset when modal opens useEffect(() => { if (!isOpen) return; setModelSettings(getInitialValues(modelParams, activeModel.name)); setPluginSettings(buildInitialSettings(pluginParams)); setOutputText(''); setError(null); promptDelayRef.current = 100; }, [isOpen, promptModal]); // Execute prompt on settings changes useEffect(() => { if (!isOpen || !executePrompt) return; let cancelled = false; const timer = setTimeout(() => { setIsLoading(true); setError(null); const settings = { model: modelSettings, plugin: pluginSettings, }; Promise.resolve(executePrompt(settings, scopedContext)) .then((result) => { if (cancelled) return; if (!result) return; setOutputText(result ?? ''); }) .catch((err) => { if (cancelled) return; setError(String(err)); }) .finally(() => { if (!cancelled) setIsLoading(false); }); }, promptDelayRef.current); return () => { cancelled = true; clearTimeout(timer); }; }, [modelSettings, pluginSettings, isOpen, executePrompt, scopedContext]); // Close modal on Escape key useEffect(() => { if (!isOpen) return; const handleKeyDown = (e) => { if (e.key === 'Escape') closePromptModal(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, closePromptModal]); /* -- Early return AFTER all hooks -- */ if (!isOpen) return null; /* -- Render helpers -- */ const { text, selectionStart, selectionEnd, replace } = scopedContext; const buildInputHtml = () => { if (!text) return ''; let extraRanges = []; if (typeof modalContext.inputHighlights === 'function') { try { extraRanges = modalContext.inputHighlights({ plugin: effectivePluginSettings }, scopedContext) || []; } catch (e) { /* ignore */ } } const boundaries = new Set([0, text.length, selectionStart, selectionEnd]); for (const r of extraRanges) { boundaries.add(Math.max(0, Math.min(r.start, text.length))); boundaries.add(Math.max(0, Math.min(r.end, text.length))); } const points = Array.from(boundaries).sort((a, b) => a - b); let html = '<div class="prompt-modal__input-body">'; for (let i = 0; i < points.length - 1; i++) { const segStart = points[i]; const segEnd = points[i + 1]; if (segStart === segEnd) continue; const inSelection = segStart >= selectionStart && segEnd <= selectionEnd; const inContext = !inSelection && extraRanges.some(r => segStart >= r.start && segEnd <= r.end); const segText = escapeHtml(text.slice(segStart, segEnd)); if (inSelection) { html += `<span class="highlight-selection">${segText}</span>`; } else if (inContext) { html += `<span class="highlight-context">${segText}</span>`; } else { html += segText; } } html += '</div>'; return html; }; const buildOutputHtml = () => { if (!outputText) return ''; const normalizedOutput = trimInvisible(outputText); const dmp = new diff_match_patch(); const diffs = dmp.diff_main(text, normalizedOutput); // Mark which positions in normalizedOutput came from additions (type 1) const addedMask = new Array(normalizedOutput.length).fill(false); let pos = 0; for (const entry of diffs) { const type = entry[0]; const diffText = entry[1]; if (type === -1 || diffText.trim().length==0) continue; if (type === 1) { for (let i = 0; i < diffText.length; i++) { addedMask[pos + i] = true; } } pos += diffText.length; } // Walk through normalizedOutput word-by-word (whitespace-separated) let html = '<div class="prompt-modal__output-body">'; let i = 0; while (i < normalizedOutput.length) { if (/\s/.test(normalizedOutput[i])) { let ws = ''; while (i < normalizedOutput.length && /\s/.test(normalizedOutput[i])) { ws += normalizedOutput[i]; i++; } html += escapeHtml(ws); } else { let word = ''; let hasAddition = false; while (i < normalizedOutput.length && !/\s/.test(normalizedOutput[i])) { if (addedMask[i]) hasAddition = true; word += normalizedOutput[i]; i++; } const escaped = escapeHtml(word); html += hasAddition ? `<span class="highlight-diff">${escaped}</span>` : escaped; } } html += '</div>'; return html; }; const inputHtml = buildInputHtml(); const outputDisplayHtml = buildOutputHtml(); const handleAccept = () => { if (isLoading || !outputText) return; replace(trimInvisible(outputText)); closePromptModal(); }; const handleOverlayClick = (e) => { if (e.target === e.currentTarget) closePromptModal(); }; return ( <div className="prompt-modal-overlay" onClick={handleOverlayClick}> <div className="prompt-modal"> <button className="prompt-modal__close" onClick={closePromptModal} aria-label="Close">×</button> <div className="prompt-modal__body"> <div className="prompt-modal__config"> <h3>Configure Effect</h3> {Object.entries(pluginParams).map(([k, v]) => ( <ConfigParam key={k} name={k} config={v} value={effectivePluginSettings[k]} onChange={(val) => { promptDelayRef.current = v.type === 'text' ? 750 : 100; setPluginSettings(s => ({ ...s, [k]: val })); }} /> ))} <h3>Model Parameters</h3> {Object.entries(modelParams).map(([k, v]) => ( <ConfigParam key={k} name={k} config={v} value={effectiveModelSettings[k]} onChange={(val) => { promptDelayRef.current = 100; setModelSettings(s => ({ ...s, [k]: val })); }} /> ))} </div> <div className="prompt-modal__preview"> <div className="prompt-modal__panes"> <div className="prompt-modal__section"> <div className="prompt-modal__section-title">Input</div> <div className="prompt-modal__doc-piece" dangerouslySetInnerHTML={{ __html: inputHtml }} /> </div> <div className="prompt-modal__section"> <div className="prompt-modal__section-title">Output</div> <div className={`prompt-modal__doc-piece prompt-modal__doc-piece--output${isLoading ? ' prompt-modal__doc-piece--output-loading' : ''}`}> {isLoading && ( <div className="prompt-modal__loading-overlay"> <div className="prompt-modal__loading-icon">⏳</div> </div> )} {error ? ( <div className="prompt-modal__error">{error}</div> ) : ( <div dangerouslySetInnerHTML={{ __html: outputDisplayHtml || '<span style="color:#999">Loading model, hang in there for 15 seconds…</span>' }} /> )} </div> </div> </div> <div className="prompt-modal__actions"> <button className="prompt-modal__btn prompt-modal__btn--secondary" onClick={closePromptModal}>Cancel</button> <button className="prompt-modal__btn prompt-modal__btn--primary" onClick={handleAccept} disabled={isLoading || !outputText} > Accept </button> </div> </div> </div> </div> </div> ); } .prompt-modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); z-index: 20000; display: flex; align-items: center; justify-content: center; padding: 24px; overscroll-behavior: contain; touch-action: none; } .prompt-modal { background: #fff; border-radius: 8px; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25); width: 100%; max-width: Min(100em, 90vw); /* intentional uppercase to pass thru min to css from scss */ max-height: 85vh; display: flex; flex-direction: column; overflow: hidden; position: relative; &__close { position: absolute; top: 8px; right: 12px; background: transparent; border: none; font-size: 22px; color: #888; cursor: pointer; z-index: 2; line-height: 1; &:hover { color: #333; } } &__body { display: flex; flex-direction: row; flex: 1; overflow: hidden; } &__config { width: 280px; flex-shrink: 0; background: #f1f3f4; color: black; padding: 20px; overflow-y: auto; h3 { font-family: system-ui, sans-serif; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; color: #555; margin: 20px 0 10px; border-bottom: 1px solid #bbb; padding-bottom: 4px; &:first-child { margin-top: 0; } } } &__preview { flex: 1; display: flex; flex-direction: column; gap: 12px; padding: 20px; overflow: hidden; background: #f8f9fa; } &__panes { flex: 1; display: flex; flex-direction: row; gap: 12px; overflow: hidden; } &__section { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; } &__section-title { font-family: system-ui, sans-serif; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #555; margin-bottom: 8px; flex-shrink: 0; position: relative; padding-right: 20px; } &__doc-piece { font-family: Georgia, "Times New Roman", serif; font-size: 15px; line-height: 1.7; color: #222; background: #fff; padding: 10px; border-radius: 4px; border: 1px dashed #ddd; overflow-wrap: break-word; flex: 1; overflow-y: auto; min-height: 0; position: relative; &::before, &::after { content: ''; position: absolute; left: 0; right: 0; height: 6px; background-repeat: repeat-x; pointer-events: none; } &::before { top: -3px; background-image: radial-gradient(circle at 50% 0, transparent 3px, #fff 4px); background-size: 8px 8px; background-position: top; } &::after { bottom: -3px; background-image: radial-gradient(circle at 50% 100%, transparent 3px, #fff 4px); background-size: 8px 8px; background-position: bottom; } } &__doc-piece--output { background: #fcfcfc; border-style: solid; &::before { background-image: radial-gradient(circle at 50% 0, transparent 3px, #fcfcfc 4px); } &::after { background-image: radial-gradient(circle at 50% 100%, transparent 3px, #fcfcfc 4px); } } &__doc-piece--output-loading { opacity: 0.3; } &__loading-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 2; pointer-events: none; } &__loading-icon { font-size: 48px; line-height: 1; } &__input-body, &__output-body { white-space: pre-wrap; } /* torn-edge pseudo-elements applied to &__doc-piece instead */ &__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } &__btn { padding: 8px 18px; border-radius: 6px; font-size: 14px; cursor: pointer; font-family: system-ui, sans-serif; border: 1px solid transparent; &--primary { background: #1a73e8; color: #fff; border-color: #1a73e8; &:hover:not(:disabled) { background: #1557b0; } &:disabled { opacity: 0.5; cursor: not-allowed; } } &--secondary { background: #fff; color: #333; border-color: #ccc; &:hover { background: #f5f5f5; } } } &__error { color: #c5221f; font-size: 13px; background: #fce8e8; padding: 8px 10px; border-radius: 4px; border: 1px solid #f5c2c7; } /* Text-highlight helpers */ .highlight-selection { background: #d4edda; padding: 0 2px; border-radius: 2px; } .highlight-sentence { background: #cce5ff; padding: 0 4px; border-radius: 3px; } .highlight-paragraph { background: #fffbea; padding: 4px 6px; border-radius: 4px; display: inline; } .highlight-diff { background: #ffeb3b; padding: 0 1px; border-radius: 2px; } .highlight-context { background: #f0f0e0; padding: 0 2px; border-radius: 2px; } } /* ConfigParam inside light panel */ .config-param { display: grid; grid-template-columns: 1fr 1.5fr; gap: 8px; align-items: center; border-radius: 4px; margin: 0.25em; &__label { font-size: 13px; font-weight: 600; color: #333; background: #e8eaed; padding: 4px 6px; border-radius: 3px; height: 100%; display: flex; align-items: center; } &__value { font-weight: 700; color: #1a73e8; margin-left: 4px; } input[type="range"] { width: 100%; cursor: pointer; } select { width: 100%; padding: 4px 6px; font-size: 13px; border-radius: 4px; border: 1px solid #999; background: #fff; color: #333; } textarea { width: 100%; min-height: 60px; padding: 6px 8px; font-size: 13px; border-radius: 4px; border: 1px solid #999; background: #fff; color: #333; resize: vertical; font-family: system-ui, sans-serif; box-sizing: border-box; } } import React, { useEffect, useState } from 'react'; /* ------------------------------------------------------------ Diff helpers ------------------------------------------------------------ */ function escapeHtml(str) { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); } function buildDiffHtml(original, suggested) { if (!window.diff_match_patch) { return escapeHtml(suggested); } try { const dmp = new diff_match_patch(); const diffs = dmp.diff_main(original, suggested); if (!Array.isArray(diffs)) { return escapeHtml(suggested); } dmp.diff_cleanupSemantic(diffs); // Mark which positions in suggested came from additions (type === 1) const addedMask = new Array(suggested.length).fill(false); let pos = 0; for (let j = 0; j < diffs.length; j++) { const type = diffs[j][0]; const text = diffs[j][1]; if (typeof text !== 'string') continue; if (type === -1) continue; if (type === 1) { for (let i = 0; i < text.length; i++) { addedMask[pos + i] = true; } } pos += text.length; } let html = ''; let i = 0; while (i < suggested.length) { if (/\s/.test(suggested[i])) { let ws = ''; while (i < suggested.length && /\s/.test(suggested[i])) { ws += suggested[i]; i++; } html += escapeHtml(ws); } else { let word = ''; let hasAddition = false; while (i < suggested.length && !/\s/.test(suggested[i])) { if (addedMask[i]) hasAddition = true; word += suggested[i]; i++; } const escaped = escapeHtml(word); html += hasAddition ? `<span class="highlight-diff">${escaped}</span>` : escaped; } } return html; } catch (err) { console.error('buildDiffHtml error:', err); return escapeHtml(suggested); } } /* ------------------------------------------------------------ SentenceSuggestPopup – fixed-position margin suggestions ------------------------------------------------------------ */ export function SentenceSuggestPopup({ useEditor }) { const { suggestions, activeSuggestionId, acceptSuggestion, rejectSuggestion } = useEditor(); const [positions, setPositions] = useState({}); useEffect(() => { function updatePositions() { const next = {}; for (const s of suggestions) { const el = document.getElementById(s.anchorId); if (el) { const rect = el.getBoundingClientRect(); next[s.id] = { top: rect.top, right: rect.right, left: rect.left, bottom: rect.bottom, height: rect.height, width: rect.width, }; } } setPositions(next); } updatePositions(); window.addEventListener('scroll', updatePositions, true); window.addEventListener('resize', updatePositions); return () => { window.removeEventListener('scroll', updatePositions, true); window.removeEventListener('resize', updatePositions); }; }, [suggestions]); const visible = suggestions.filter(s => s.anchorId === activeSuggestionId); if (!visible.length) return null; return ( <> {visible.map((s) => { let pos = positions[s.id]; if (!pos) { const el = document.getElementById(s.anchorId); if (el) { const r = el.getBoundingClientRect(); pos = { top: r.top, right: r.right, left: r.left, bottom: r.bottom, height: r.height, width: r.width }; } } if (!pos) return null; return ( <div key={s.id} className="sentence-suggest-popup" style={{ position: 'fixed', top: pos.top, left: pos.right + 12, zIndex: 10000, maxWidth: 260, }} > <div className="sentence-suggest-popup__body" dangerouslySetInnerHTML={{ __html: buildDiffHtml(s.originalText, s.suggestedText), }} /> <div className="sentence-suggest-popup__actions"> <button className="sentence-suggest-popup__btn sentence-suggest-popup__btn--accept" onClick={() => acceptSuggestion(s.id)} > Accept </button> <button className="sentence-suggest-popup__btn sentence-suggest-popup__btn--reject" onClick={() => rejectSuggestion(s.id)} > Reject </button> </div> </div> ); })} </> ); } /* ------------------------------------------------------------ Autotype – inline anchors + sentence suggestion overlays ------------------------------------------------------------ */ .autotype-anchor { border-bottom: 1.5px dashed rgba(26, 115, 232, 0.45); transition: border-color 0.2s ease; &:hover { border-bottom-color: rgba(26, 115, 232, 0.85); } } .sentence-suggest-popup { background: #fff; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); padding: 10px 12px; font-family: Georgia, "Times New Roman", serif; font-size: 14px; line-height: 1.5; color: #222; pointer-events: auto; &__body { margin-bottom: 8px; white-space: pre-wrap; overflow-wrap: break-word; } &__actions { display: flex; gap: 6px; justify-content: flex-end; } &__btn { padding: 5px 12px; border-radius: 4px; font-size: 13px; font-family: system-ui, sans-serif; cursor: pointer; border: 1px solid transparent; transition: background 0.15s ease, border-color 0.15s ease; &--accept { background: #1a73e8; color: #fff; border-color: #1a73e8; &:hover { background: #1557b0; } } &--reject { background: #fff; color: #555; border-color: #ccc; &:hover { background: #f5f5f5; } } } } .highlight-diff { background: #ffeb3b; padding: 0 1px; border-radius: 2px; } .sidebar { position: relative; display: flex; flex-direction: column; border-left: 1px solid #ccc; background: #fff; flex-shrink: 0; box-sizing: border-box; height: 100vh; width: 0; padding: 0; overflow: hidden; transition: width 0.25s ease, padding 0.25s ease; &--expanded { width: 280px; padding: 12px; } &__close { align-self: flex-end; min-width: 28px; height: 28px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #333; .material-icons-outlined { font-size: 20px; } &:hover { background: #f3f3f3; } } &__content { margin-top: 8px; overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; } &__footer { border-top: 1px solid #e0e0e0; padding-top: 12px; margin-top: 8px; flex-shrink: 0; } &__reset-btn { width: 100%; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font-family: system-ui, sans-serif; font-size: 12px; color: #333; cursor: pointer; &:hover { background: #f3f3f3; } &--danger { border-color: #e74c3c; color: #e74c3c; &:hover { background: #fdf1f0; } } &--secondary { border-color: #ccc; color: #666; &:hover { background: #f3f3f3; } } } &__reset-warning { display: flex; flex-direction: column; gap: 8px; } &__reset-note { margin: 0; font-family: system-ui, sans-serif; font-size: 11px; color: #e74c3c; line-height: 1.4; } &__reset-actions { display: flex; flex-direction: column; gap: 6px; } &__floating-toggle { position: absolute; bottom: 12px; right: 12px; z-index: 100; width: 32px; height: 32px; border: none; border-radius: 4px; background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #666; opacity: 0.7; &:hover { opacity: 1; color: #333; } .material-icons-outlined { font-size: 20px; } } } .setup-panel { &__header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 8px; } &__title { margin: 0 0 4px; padding-bottom: 8px; border-bottom: 1px solid #e0e0e0; font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #333; } & + & { .setup-panel__title { margin-top: 16px; } } &__model { font-family: monospace; font-size: 11px; color: #666; word-break: break-all; } &__toggle { display: inline-flex; align-items: center; gap: 4px; font-family: system-ui, sans-serif; font-size: 12px; color: #333; cursor: pointer; white-space: nowrap; flex-shrink: 0; input[type="checkbox"] { accent-color: #333; cursor: pointer; } } &__params { display: flex; flex-direction: column; gap: 12px; } &__param { display: grid; grid-template-columns: 1fr 1.5fr; gap: 8px; align-items: start; border-radius: 4px; min-width: 0; & > * { min-width: 0; } } &__label-row { display: flex; align-items: center; gap: 6px; background: #e8eaed; padding: 4px 6px; border-radius: 3px; align-self: stretch; } &__label { font-family: system-ui, sans-serif; font-size: 12px; font-weight: 600; color: #333; text-transform: capitalize; cursor: default; flex: 1; } &__range { display: flex; align-items: center; gap: 8px; input[type="range"] { flex: 1; min-width: 0; accent-color: #333; } } &__value { font-family: monospace; font-size: 12px; color: #333; min-width: 36px; text-align: right; } &__select { width: 100%; padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font-family: system-ui, sans-serif; font-size: 12px; color: #333; cursor: pointer; &:focus { outline: 2px solid rgba(0, 0, 0, 0.1); outline-offset: 1px; } } &__textarea { width: 100%; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font-family: system-ui, sans-serif; font-size: 12px; color: #333; resize: vertical; box-sizing: border-box; &:focus { outline: 2px solid rgba(0, 0, 0, 0.1); outline-offset: 1px; } } } import React, { useState, useEffect, useCallback } from 'react'; function clearAllStorage() { if (typeof localStorage !== 'undefined') { localStorage.clear(); } window.location.reload(); } function ResetButton() { const [warned, setWarned] = useState(false); if (!warned) { return ( <button type="button" className="sidebar__reset-btn" onClick={() => setWarned(true)} > Reset editor and settings </button> ); } return ( <div className="sidebar__reset-warning"> <p className="sidebar__reset-note"> This will delete your document and reset all settings. Proceed? </p> <div className="sidebar__reset-actions"> <button type="button" className="sidebar__reset-btn sidebar__reset-btn--danger" onClick={clearAllStorage} > Yes, reset everything </button> <button type="button" className="sidebar__reset-btn sidebar__reset-btn--secondary" onClick={() => setWarned(false)} > Cancel </button> </div> </div> ); } function getStorageKey(modelKey) { return `editor-model-params-${modelKey}`; } /* Initial param values from an explicit (already-prefixed) storage key. */ export function getInitialValuesFromKey(params, fullStorageKey) { try { const saved = JSON.parse(localStorage.getItem(fullStorageKey) || '{}'); const values = {}; for (const [keyName, spec] of Object.entries(params)) { values[keyName] = saved[keyName] !== undefined ? saved[keyName] : spec.default; } return values; } catch { return Object.fromEntries( Object.entries(params).map(([keyName, spec]) => [keyName, spec.default]) ); } } export function getInitialValues(params, modelKey = 'activeModel') { return getInitialValuesFromKey(params, getStorageKey(modelKey)); } function saveValues(values, modelKey = 'activeModel') { const key = getStorageKey(modelKey); try { localStorage.setItem(key, JSON.stringify(values)); } catch { // ignore storage errors } } /** * SetupPanel – parameter configurator. * * Usage modes: * 1) SetupPanel() – reads model from window.activeModel * 2) SetupPanel({ modelKey }) – reads model from window[modelKey] * 3) SetupPanel({ title, params, storageKey }) – explicit mode (for plugins) */ export function SetupPanel({ modelKey, title: explicitTitle, params: explicitParams, storageKey: explicitStorageKey, topRow }) { const isDefaultMode = !explicitParams && !modelKey; const [selectedModelName, setSelectedModelName] = useState(() => { if (isDefaultMode) return window.activeModel?.name || ''; return ''; }); const model = !explicitParams ? (isDefaultMode ? window.availableModels?.find(m => m.name === selectedModelName) || window.activeModel : window[modelKey]) : null; if (!model && !explicitParams) return null; const title = explicitTitle || model?.name || 'Setup'; const params = explicitParams || model?.params || {}; const effectiveModelKey = isDefaultMode ? (selectedModelName || 'activeModel') : (modelKey || 'activeModel'); const storageKey = explicitStorageKey || getStorageKey(effectiveModelKey); const [values, setValues] = useState(() => getInitialValuesFromKey(params, storageKey)); useEffect(() => { setValues(getInitialValuesFromKey(params, storageKey)); }, [params, storageKey]); const updateValue = useCallback((key, nextValue) => { setValues(prev => { const updated = { ...prev, [key]: nextValue }; try { localStorage.setItem(storageKey, JSON.stringify(updated)); } catch { // ignore storage errors } return updated; }); }, [storageKey]); return ( <div className="setup-panel"> <div className="setup-panel__header"> <div> {isDefaultMode ? ( <h3 className="setup-panel__title">Text-Diffusion Model</h3> ) : ( <> <h3 className="setup-panel__title">{title}</h3> {model?.name && <div className="setup-panel__model">{model.name}</div>} </> )} </div> </div> <div className="setup-panel__params"> {isDefaultMode && window.availableModels?.length > 1 && ( <div className="setup-panel__param"> <div className="setup-panel__label-row"> <label className="setup-panel__label">model</label> </div> <select className="setup-panel__select" value={selectedModelName} onChange={(e) => { const newName = e.target.value; setSelectedModelName(newName); const newModel = window.availableModels.find(m => m.name === newName); if (newModel) { window.activeModel = newModel; localStorage.setItem('editor-active-model-name', newName); } }} > {window.availableModels.map(m => ( <option key={m.name} value={m.name}>{m.name}</option> ))} </select> </div> )} {topRow} {Object.entries(params) .filter(([, spec]) => !spec.showIf || spec.showIf(values)) .map(([key, spec]) => ( <div key={key} className="setup-panel__param" title={spec.hint || undefined}> <div className="setup-panel__label-row"> <label className="setup-panel__label" htmlFor={`${storageKey}-${key}`}> {spec.name || key} </label> </div> {spec.type === 'range' && ( <div className="setup-panel__range"> <input id={`${storageKey}-${key}`} type="range" min={spec.min} max={spec.max} step={spec.step} value={values[key]} onChange={e => updateValue(key, e.target.valueAsNumber)} /> <span className="setup-panel__value">{values[key]}</span> </div> )} {spec.type === 'enum' && ( <select id={`${storageKey}-${key}`} className="setup-panel__select" value={values[key]} onChange={e => updateValue(key, e.target.value)} > {spec.values.map(opt => ( <option key={opt.value} value={opt.value}> {opt.title} </option> ))} </select> )} {spec.type === 'text' && ( <textarea id={`${storageKey}-${key}`} className="setup-panel__textarea" value={values[key]} rows={spec.rows || 4} placeholder={spec.placeholder || ''} onChange={e => updateValue(key, e.target.value)} /> )} {spec.type === 'checkbox' && ( <label className="setup-panel__toggle" htmlFor={`${storageKey}-${key}`}> <input id={`${storageKey}-${key}`} type="checkbox" checked={!!values[key]} onChange={e => updateValue(key, e.target.checked)} /> </label> )} </div> ))} </div> </div> ); } export function AutotypePluginSetupPanels({ useEditor }) { const { autotypePlugins, getAutotypeParams, isAutotypeEnabled, setAutotypeEnabledForId } = useEditor(); return ( <> {autotypePlugins.map(plugin => { const params = getAutotypeParams(plugin.id) || {}; const enabled = isAutotypeEnabled(plugin.id); return ( <SetupPanel key={plugin.id} title={plugin.displayName || plugin.id} params={params} storageKey={`editor-autotype-params-${plugin.id}`} topRow={ <div className="setup-panel__param"> <div className="setup-panel__label-row"> <label className="setup-panel__label">Enabled</label> </div> <label className="setup-panel__toggle"> <input type="checkbox" checked={enabled} onChange={(e) => setAutotypeEnabledForId(plugin.id, e.target.checked)} /> </label> </div> } /> ); })} </> ); } export function Sidebar({ children }) { const STORAGE_KEY = "editor-sidebar-collapsed"; const [isCollapsed, setIsCollapsed] = useState(() => { try { const stored = localStorage.getItem(STORAGE_KEY); return stored === null ? true : stored === "true"; } catch { return true; } }); useEffect(() => { try { localStorage.setItem(STORAGE_KEY, String(isCollapsed)); } catch { // silently ignore storage errors } }, [isCollapsed]); const toggle = () => setIsCollapsed((prev) => !prev); return ( <> {isCollapsed && ( <button type="button" className="sidebar__floating-toggle" onClick={toggle} title="Expand sidebar" > <span className="material-icons-outlined">settings</span> </button> )} <aside className={`sidebar ${!isCollapsed ? 'sidebar--expanded' : ''}`}> <button type="button" className="sidebar__close" onClick={toggle} title="Collapse sidebar" > <span className="material-icons-outlined">close</span> </button> <div className="sidebar__content">{children}</div> <div className="sidebar__footer"> <ResetButton /> </div> </aside> </> ); } // ------------------------------------------------------------ // 1. Caret & word selection // ------------------------------------------------------------ export function getCaretRangeFromPoint(x, y) { if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); if (!pos) return null; const r = document.createRange(); r.setStart(pos.offsetNode, pos.offset); r.setEnd(pos.offsetNode, pos.offset); return r; } if (document.caretRangeFromPoint) { return document.caretRangeFromPoint(x, y); } return null; } export function selectWordAt(range) { if (!range || !range.collapsed) return range; const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) return range; const text = node.textContent; let start = range.startOffset; while (start > 0 && !/\s/.test(text[start - 1])) start--; let end = range.startOffset; while (end < text.length && !/\s/.test(text[end])) end++; const r = document.createRange(); r.setStart(node, start); r.setEnd(node, end); return r; } // ------------------------------------------------------------ // 2. DOM helpers // ------------------------------------------------------------ const BLOCK_TAGS = new Set([ 'p', 'li', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'section', 'article', 'pre', 'header', 'footer', ]); function getBlockParent(node, ancestor) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el && el !== ancestor) { if (BLOCK_TAGS.has(el.tagName?.toLowerCase())) return el; el = el.parentElement; } return null; } export function getFlatOffset(targetNode, targetOffset, ancestor) { let count = 0; const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT); let prevBlock = null; let n; while ((n = walker.nextNode())) { const block = getBlockParent(n, ancestor); if (prevBlock && block !== prevBlock) { count += 1; // newline between blocks } if (n === targetNode) return count + targetOffset; count += n.textContent.length; prevBlock = block; } return count; } export function getParagraphElement(node, root) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el && el !== root) { const tag = el.tagName?.toLowerCase(); if (['p','li','div','h1','h2','h3','h4','h5','h6','blockquote','section','article'].includes(tag)) { return el; } el = el.parentElement; } return root; } export function findNodeAndOffsetAtChar(paragraphElement, charOffset) { let count = 0; const walker = document.createTreeWalker(paragraphElement, NodeFilter.SHOW_TEXT); let prevBlock = null; let n; while ((n = walker.nextNode())) { const block = getBlockParent(n, paragraphElement); if (prevBlock && block !== prevBlock) { count += 1; // newline between blocks if (count >= charOffset) { return { node: n, offset: 0 }; } } const len = n.textContent.length; if (count + len >= charOffset) { return { node: n, offset: charOffset - count }; } count += len; prevBlock = block; } // If offset is at the very end if (n) { return { node: n, offset: n.textContent.length }; } return null; } // ------------------------------------------------------------ // 3. Mutation helpers // ------------------------------------------------------------ export function replaceRange(range, text) { let idsToRemove = []; const anchor = getAutotypeAnchor(range.commonAncestorContainer); if (anchor) { const scope = anchor.parentElement; const startFlat = getFlatOffset(range.startContainer, range.startOffset, scope); const endFlat = getFlatOffset(range.endContainer, range.endOffset, scope); idsToRemove.push(...unwrapAutotypeAnchor(anchor)); const startInfo = findNodeAndOffsetAtChar(scope, startFlat); const endInfo = findNodeAndOffsetAtChar(scope, endFlat); if (startInfo && endInfo) { range = document.createRange(); range.setStart(startInfo.node, startInfo.offset); range.setEnd(endInfo.node, endInfo.offset); } } // operate immediately while the range is still valid range.deleteContents(); const tn = document.createTextNode(text); range.insertNode(tn); range.setStartAfter(tn); range.collapse(true); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); return idsToRemove; } export function wrapRange(range, tagName) { const el = document.createElement(tagName); el.appendChild(range.extractContents()); range.insertNode(el); const sel = window.getSelection(); const r = document.createRange(); r.selectNode(el); sel.removeAllRanges(); sel.addRange(r); } // ------------------------------------------------------------ // 4. Sentence completion helper // ------------------------------------------------------------ // ------------------------------------------------------------ // 4. Selection-persist highlights (visual-only, not contentEditable) // ------------------------------------------------------------ const HIGHLIGHT_TAG = 'mark'; const HIGHLIGHT_CLASS = 'editor-selection-persist'; export function wrapRangeInHighlight(range) { // Wrap the range contents in a <mark class="editor-selection-persist"> const wrapper = document.createElement(HIGHLIGHT_TAG); wrapper.className = HIGHLIGHT_CLASS; wrapper.dataset.selectionPersist = 'true'; wrapper.appendChild(range.extractContents()); range.insertNode(wrapper); wrapper.parentNode?.normalize(); return wrapper; } export function unwrapSelectionHighlights(root) { if (!root) return; root.querySelectorAll(`${HIGHLIGHT_TAG}.${HIGHLIGHT_CLASS}`).forEach(el => { const parent = el.parentNode; if (!parent) return; const children = Array.from(el.childNodes); if (children.length) { el.replaceWith(...children); } else { el.remove(); } parent.normalize(); }); } export function isInsideAutotypeAnchor(node) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el) { if (el.classList?.contains('autotype-anchor')) return true; el = el.parentElement; } return false; } export function getAutotypeAnchor(node) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el) { if (el.classList?.contains('autotype-anchor')) return el; el = el.parentElement; } return null; } export function unwrapAutotypeAnchor(anchor) { const sel = window.getSelection(); let savedStartFlat = null; let savedEndFlat = null; let savedScope = null; if (sel.rangeCount) { const range = sel.getRangeAt(0); if (anchor.contains(range.commonAncestorContainer)) { savedScope = anchor.parentElement; if (savedScope) { savedStartFlat = getFlatOffset(range.startContainer, range.startOffset, savedScope); savedEndFlat = getFlatOffset(range.endContainer, range.endOffset, savedScope); } } } const parent = anchor.parentNode; if (!parent) return []; const suggestionIds = []; if (anchor.dataset.autotypeSuggestions) { try { const list = JSON.parse(anchor.dataset.autotypeSuggestions); if (Array.isArray(list)) { suggestionIds.push(...list.map(s => s.id)); } } catch (e) { // ignore malformed JSON } } const children = Array.from(anchor.childNodes); if (children.length) { anchor.replaceWith(...children); } else { anchor.remove(); } parent?.normalize(); if (savedScope && savedStartFlat !== null && savedEndFlat !== null) { const startInfo = findNodeAndOffsetAtChar(savedScope, savedStartFlat); const endInfo = findNodeAndOffsetAtChar(savedScope, savedEndFlat); if (startInfo && endInfo) { const newRange = document.createRange(); newRange.setStart(startInfo.node, startInfo.offset); newRange.setEnd(endInfo.node, endInfo.offset); sel.removeAllRanges(); sel.addRange(newRange); } } return suggestionIds; } export function getSentenceAtCaret(range, root) { const block = getParagraphElement(range.startContainer, root); if (!block) return null; const full = block.textContent; const caret = getFlatOffset(range.startContainer, range.startOffset, block); let s = caret; while (s > 0 && !'.!?'.includes(full[s - 1])) s--; while (s < caret && /\s/.test(full[s])) s++; let e = caret; while (e < full.length && !'.!?'.includes(full[e])) e++; if (e < full.length && '.!?'.includes(full[e])) e++; const text = full.slice(s, e).trim(); if (!text) return null; const startInfo = findNodeAndOffsetAtChar(block, s); const endInfo = findNodeAndOffsetAtChar(block, e); if (!startInfo || !endInfo) return null; const r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); return { range: r, text, container: block }; } export function getLastCompletedSentence(range, root, isEnter = false) { const currentBlock = getParagraphElement(range.startContainer, root); if (!currentBlock) return null; function extractSentence(block, startChar, endChar) { const text = block.textContent.slice(startChar, endChar).trim(); if (!text) return null; const startInfo = findNodeAndOffsetAtChar(block, startChar); const endInfo = findNodeAndOffsetAtChar(block, endChar); if (!startInfo || !endInfo) return null; const r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); return { range: r, text, container: block }; } function findSentenceInBlock(block, fallbackToWhole = false) { const text = block.textContent; let end = text.length; while (end > 0 && /\s/.test(text[end - 1])) end--; let terminatorPos = -1; for (let i = end - 1; i >= 0; i--) { if ('.!?'.includes(text[i])) { terminatorPos = i; break; } } if (terminatorPos !== -1) { // find the start of this sentence (after the previous terminator) let start = terminatorPos - 1; while (start >= 0 && !'.!?'.includes(text[start])) start--; start++; // move past the previous terminator (or to 0 if at beginning) while (start < terminatorPos && /\s/.test(text[start])) start++; return extractSentence(block, start, terminatorPos + 1); } if (fallbackToWhole) { let start = 0; while (start < end && /\s/.test(text[start])) start++; return extractSentence(block, start, end); } return null; } if (isEnter) { const prevBlock = currentBlock?.previousElementSibling; if (prevBlock && prevBlock !== root) { const res = findSentenceInBlock(prevBlock, true); if (res) return res; } return findSentenceInBlock(currentBlock, true); } return findSentenceInBlock(currentBlock, false); } // ------------------------------------------------------------ // 5. Context helpers (scope-aware) // ------------------------------------------------------------ // A context scope decides how far around the selection the text context // (what the model sees, and what Accept replaces) extends: // sentence – the sentence(s) containing the selection // sibling-sentences – adds the previous and the next sentence // paragraph – every sentence of the paragraph(s) containing the selection // sibling-paragraphs – adds the previous and the next paragraph // document – the entire document export const CONTEXT_SCOPES = [ 'sentence', 'sibling-sentences', 'paragraph', 'sibling-paragraphs', 'document', ]; function getTextWithBlockBreaks(element) { let text = ''; const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let prevBlock = null; let n; while ((n = walker.nextNode())) { const block = getBlockParent(n, element); if (prevBlock && block !== prevBlock) { text += '\n'; } text += n.textContent; prevBlock = block; } return text; } /* Sentence spans [{start, end}] within one block's text. A run of terminators ("...", "!?") stays attached to the sentence it ends, and leading whitespace between sentences belongs to no span. */ function splitSentenceSpans(text) { const spans = []; const n = text.length; let i = 0; while (i < n) { while (i < n && /\s/.test(text[i])) i++; if (i >= n) break; let e = i; while (e < n && !'.!?'.includes(text[e])) e++; if (e < n) { while (e + 1 < n && '.!?'.includes(text[e + 1])) e++; e++; } spans.push({ start: i, end: e }); i = e; } return spans; } /* Block groups: maximal runs of text nodes sharing the same closest block element, as flat [start, end) ranges in getTextWithBlockBreaks(root) coordinates (the '\n' between blocks belongs to no group). Mirrors the transition rule of getTextWithBlockBreaks/getFlatOffset exactly, so the flat offsets stay consistent across all three. */ function getBlockGroups(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const groups = []; let flat = 0; let prevBlock = null; let cur = null; let n; while ((n = walker.nextNode())) { const block = getBlockParent(n, root); if (prevBlock && block !== prevBlock) { if (cur) { cur.end = flat; groups.push(cur); } flat += 1; // the '\n' between blocks cur = null; } if (!cur) cur = { element: block || root, start: flat }; flat += n.textContent.length; cur.end = flat; prevBlock = block; } if (cur) groups.push(cur); return groups; } export function getContextInfo(range, root, scope = 'sentence') { const full = getTextWithBlockBreaks(root); const selStart = getFlatOffset(range.startContainer, range.startOffset, root); const selEnd = getFlatOffset(range.endContainer, range.endOffset, root); const groups = getBlockGroups(root); if (!groups.length || selEnd < selStart) return null; // Index of the last group that starts at or before `pos` (positions on a // block boundary belong to the preceding group). const groupIndexAt = (pos) => { let k = 0; for (let i = 0; i < groups.length; i++) { if (groups[i].start <= pos) k = i; else break; } return k; }; const gi1 = groupIndexAt(selStart); const gi2 = groupIndexAt(selEnd); let contextStart = null; let contextEnd = null; if (scope === 'document') { contextStart = 0; contextEnd = full.length; } else if (scope === 'paragraph' || scope === 'sibling-paragraphs') { let a = gi1; let b = gi2; if (scope === 'sibling-paragraphs') { if (gi1 > 0) a = gi1 - 1; if (gi2 < groups.length - 1) b = gi2 + 1; } contextStart = groups[a].start; contextEnd = groups[b].end; } else { // 'sentence' and 'sibling-sentences' live inside the block groups const spansCache = new Map(); const groupSpans = (gi) => { if (!spansCache.has(gi)) { const g = groups[gi]; spansCache.set(gi, splitSentenceSpans(full.slice(g.start, g.end))); } return spansCache.get(gi); }; // Index of the sentence span containing flat position `pos` within group // `gi`. A position in the whitespace between sentences belongs to the // NEXT sentence (matching the previous sentence-bound behaviour). const spanIndexAt = (gi, pos) => { const spans = groupSpans(gi); if (!spans.length) return 0; const p = pos - groups[gi].start; let k = 0; for (let i = 0; i < spans.length; i++) { if (spans[i].start <= p) k = i; else break; } if (p >= spans[k].end && k + 1 < spans.length) return k + 1; return k; }; const sentStart = (gi, i) => groups[gi].start + groupSpans(gi)[i].start; const sentEnd = (gi, i) => groups[gi].start + groupSpans(gi)[i].end; const i1 = spanIndexAt(gi1, selStart); const i2 = spanIndexAt(gi2, selEnd); contextStart = sentStart(gi1, i1); contextEnd = sentEnd(gi2, i2); if (scope === 'sibling-sentences') { // One sentence further back — within the group, or (at a group edge) // the last sentence of the previous group. if (i1 > 0) { contextStart = sentStart(gi1, i1 - 1); } else if (gi1 > 0) { const prevSpans = groupSpans(gi1 - 1); if (prevSpans.length) contextStart = sentStart(gi1 - 1, prevSpans.length - 1); } // One sentence further forward — within the group, or the first // sentence of the next group. if (i2 < groupSpans(gi2).length - 1) { contextEnd = sentEnd(gi2, i2 + 1); } else if (gi2 < groups.length - 1) { const nextSpans = groupSpans(gi2 + 1); if (nextSpans.length) contextEnd = sentEnd(gi2 + 1, 0); } } } if (contextStart === null || contextEnd === null || contextEnd <= contextStart) return null; // Clamp the selection into the context (it should always be inside, but // never hand the model offsets outside the context text). const s = Math.max(contextStart, Math.min(selStart, contextEnd)); const e = Math.max(contextStart, Math.min(selEnd, contextEnd)); // Block elements the context spans (used to clean up empty shells left // behind when replacing across multiple paragraphs). const coveredBlocks = groups .filter((g) => g.end > contextStart && g.start < contextEnd) .map((g) => g.element); return { text: full.slice(contextStart, contextEnd), selectionStart: s - contextStart, selectionEnd: e - contextStart, containerElement: root, _docStart: contextStart, _docEnd: contextEnd, _coveredBlocks: coveredBlocks, _scope: scope, }; } export function replaceContextRange(ctxInfo, newText) { const { containerElement, _docStart, _docEnd, _coveredBlocks } = ctxInfo; let startInfo = findNodeAndOffsetAtChar(containerElement, _docStart); let endInfo = findNodeAndOffsetAtChar(containerElement, _docEnd); if (!startInfo || !endInfo) return []; let r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); let idsToRemove = []; const anchor = getAutotypeAnchor(r.commonAncestorContainer); if (anchor && containerElement.contains(anchor)) { idsToRemove.push(...unwrapAutotypeAnchor(anchor)); startInfo = findNodeAndOffsetAtChar(containerElement, _docStart); endInfo = findNodeAndOffsetAtChar(containerElement, _docEnd); if (!startInfo || !endInfo) return idsToRemove; r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); } // Focus the editable surface first so execCommand targets the right document let focusTarget = containerElement; while (focusTarget && focusTarget.contentEditable !== 'true') { focusTarget = focusTarget.parentElement; } if (focusTarget) focusTarget.focus(); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); // Use the browser's native undo stack for this replacement document.execCommand('insertText', false, newText); containerElement.normalize(); // Contexts spanning several paragraphs (paragraph/sibling-paragraphs/ // document scopes) leave the partially-covered block elements behind as // empty shells — the new text lands in the first one. Drop the blocks // that are now completely empty. if (_coveredBlocks && _coveredBlocks.length > 1) { for (const block of _coveredBlocks) { if (!block || !block.isConnected || block === containerElement) continue; if (block.querySelector('img,video,audio,canvas,svg,iframe,br,hr,input,textarea')) continue; if ((block.textContent || '').trim() === '') block.remove(); } containerElement.normalize(); } return idsToRemove; } /* ============================================================ ddlm-api – shared LiteLLM streaming utilities ============================================================ */ export const ENDPOINT = 'https://litellm.stream.cavi.au.dk/v1/chat/completions'; export function isDiffusionGemmaModel(modelName) { return /diffusiongemma/i.test(modelName || ''); } /** * Build the model-specific sampler knobs for a /v1/chat/completions request * (sent via `extra_body` through the LiteLLM proxy to the dllm-fastapi * server). Keys map 1:1 to ChatReq fields on the server; each model kind * only consumes its own knobs (the server ignores the rest). * * - MDLM-style samplers (LLaDA / Dream / Qwen3-diffusion): * temperature, steps, remasking, block_size, cfg_scale, * stochastic_transfer, seed * - DiffusionGemma (block diffusion, encoder-decoder): * steps, entropy_bound, entropy_threshold, stability_threshold, * pin_anchor_alpha (infill-only), max_tokens, max_temperature / * min_temperature (annealed linearly across denoising steps), seed. * prefix_thought_channel and pin_eos_tail are internal-only (always * true) and not exposed in the UI: the first pins the empty * thought-channel header, the second pins EOT+pad after the suggestion * span so the model wraps up instead of filling the whole 256-token * canvas block. No single global temperature — the annealing pair * replaces it. */ export function buildModelRequestBody(settings, modelName) { const model = modelName || window.activeModel?.name || ''; const s = settings || {}; if (isDiffusionGemmaModel(model)) { return { seed: s.seed ?? 42, steps: s.steps ?? 48, includeSteps: false, entropy_bound: s.entropy_bound ?? 0.1, entropy_threshold: s.entropy_threshold ?? 0.005, stability_threshold: s.stability_threshold ?? 1, pin_anchor_alpha: s.pin_anchor_alpha ?? 1, max_tokens: s.max_tokens ?? 128, max_temperature: s.max_temperature ?? 0.8, min_temperature: s.min_temperature ?? 0.4, prefix_thought_channel: true, pin_eos_tail: true, }; } // MDLM-style samplers (LLaDA / Dream / Qwen3-diffusion) return { seed: s.seed ?? 42, steps: s.steps ?? 32, includeSteps: false, temperature: s.temp ?? 0.7, remasking: s.remasking ?? 'low_confidence', block_size: s.blocksize ?? 24, cfg_scale: s.cfg_scale ?? 0, stochastic_transfer: (s.stochastic_transfer === true || s.stochastic_transfer === 'true'), }; } /* ============================================================ Masking-pattern engine – diffusion auto-typist Masked words are replaced by '…' (the dllm server turns each '…' into a mask token and refills it); which words get masked is decided by the selected pattern below. The whitespace around masked words stays verbatim: the models' tokenizers put a word's leading space inside its word piece, so those spaces must survive into the request text (see applyMask for why). ============================================================ */ /* Param-storage prefixes for the per-kind diffusion model knobs (both kinds define steps / max_tokens / seed with different defaults, so they are namespaced to avoid key collisions). */ export const MDLM_PARAMS_PREFIX = 'mdlm.'; export const DIFFUSIONGEMMA_PARAMS_PREFIX = 'gemma.'; /* Tokens per word: how many mask tokens each masked word becomes (the per-word generation budget for the refill). Bounds are shared by the params UI and the runtime clamp. */ export const MASK_TOKENS_PER_WORD = { min: 1, max: 8, default: 1 }; /* RiTa.js word classes for the 'rita' pattern. `tags` are Penn Treebank tag prefixes matched against RiTa.pos(word). */ export const RITA_CLASSES = [ { value: 'nouns', title: 'Nouns', tags: ['nn'] }, { value: 'verbs', title: 'Verbs', tags: ['vb'] }, { value: 'adjectives', title: 'Adjectives', tags: ['jj'] }, { value: 'adverbs', title: 'Adverbs', tags: ['rb'] }, { value: 'prepositions', title: 'Prepositions', tags: ['in'] }, { value: 'pronouns', title: 'Pronouns', tags: ['prp', 'pp'] }, { value: 'determiners', title: 'Determiners/articles', tags: ['dt'] }, { value: 'conjunctions', title: 'Conjunctions', tags: ['cc'] }, { value: 'numbers', title: 'Numbers', tags: ['cd'] }, { value: 'modals', title: 'Modal verbs', tags: ['md'] }, { value: 'interjections', title: 'Interjections', tags: ['uh'] }, ]; let ritaPromise = null; /* Lazy-load RiTa.js (POS tagger) from CDN, at most once. The npm dist build is a UMD bundle: when the page defines any of the module/exports/define globals (WPM/babel leave such globals behind), the bundle registers itself as a CommonJS/AMD module and never sets window.RiTa. Fetching the text and evaluating it inside a wrapper whose parameters shadow those three identifiers forces the browser branch (window.RiTa = …) without touching the page's globals. */ export function loadRiTa() { if (window.RiTa) return Promise.resolve(window.RiTa); if (!ritaPromise) { ritaPromise = (async () => { try { const response = await fetch('https://cdn.jsdelivr.net/npm/rita@2/dist/rita.js'); if (!response.ok) throw new Error(`RiTa.js CDN responded ${response.status}`); const src = await response.text(); // Indirect eval runs in global scope; the wrapper's parameters // shadow module/exports/define for everything evaluated inside. (0, eval)('(function (module, exports, define) {\n' + src + '\n}).call(window)'); if (window.RiTa) return window.RiTa; throw new Error('RiTa.js loaded but window.RiTa is missing'); } catch (err) { ritaPromise = null; throw err; } })(); } return ritaPromise; } /* Word tokens with character ranges: [{word, start, end}]. */ export function tokenizeWords(text) { const tokens = []; const re = /\S+/g; let m; while ((m = re.exec(text)) !== null) { tokens.push({ word: m[0], start: m.index, end: m.index + m[0].length }); } return tokens; } /* Replace each masked word with `tokensPerWord` '…'; everything else — including the whitespace flanking masked words — stays verbatim. The flanking spaces must survive: the models' tokenizers carry a word's leading space inside the word piece ('▁the'), so deleting the spaces pins spaceless pieces ('the') against the mask tokens — a shape natural text only has inside hyphenated compounds, which the model then completes with dashes ('follow-the-instructions-above'). Spacing is still re-decided per refill: each mask is filled with a word piece, and that piece brings (or lacks) its own leading space. */ export function applyMask(text, tokens, maskedIndices, tokensPerWord = 1) { const n = tokens.length; if (!n) return text; const perWord = Math.max(1, Math.floor(Number(tokensPerWord) || 1)); const isMasked = (i) => maskedIndices.has(i); let out = ''; const seg = (start, end, mask, dots = 1) => { if (start >= end) return; out += mask ? '…'.repeat(dots) : text.slice(start, end); }; seg(0, tokens[0].start, false); for (let i = 0; i < n; i++) { const t = tokens[i]; seg(t.start, t.end, isMasked(i), perWord); const gapEnd = i + 1 < n ? tokens[i + 1].start : text.length; seg(t.end, gapEnd, false); } return out; } /* 1-based Fibonacci positions (1, 2, 3, 5, 8, 13, 21, …) up to n. */ export function fibonacciIndexSet(n) { const out = new Set(); let a = 1; let b = 2; while (a <= n) { out.add(a); const next = a + b; a = b; b = next; } return out; } /* 1-based prime positions (2, 3, 5, 7, 11, 13, …) up to n. */ export function primeIndexSet(n) { const out = new Set(); for (let c = 2; c <= n; c++) { let isPrime = true; for (let d = 2; d * d <= c; d++) { if (c % d === 0) { isPrime = false; break; } } if (isPrime) out.add(c); } return out; } function clampNumber(value, min, max, fallback) { const n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, n)); } /* * Compute the set of word indices (0-based, into `tokens`) masked by a * single-shot pattern. The 'chunk' pattern (multi-round progression) is * handled by runMaskedAutotype; anything unknown returns null. */ export async function computeMaskIndices(tokens, pattern, settings) { const n = tokens.length; const s = settings || {}; const masked = new Set(); switch (pattern) { case 'random': { const percent = clampNumber(s.mask_random_percent, 5, 50, 25); const maxCount = Math.max(1, Math.round((n * percent) / 100)); const count = 1 + Math.floor(Math.random() * maxCount); const indices = []; for (let i = 0; i < n; i++) indices.push(i); for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = indices[i]; indices[i] = indices[j]; indices[j] = tmp; } for (let i = 0; i < count && i < indices.length; i++) masked.add(indices[i]); break; } case 'every_other': { const start = Math.random() < 0.5 ? 0 : 1; for (let i = start; i < n; i += 2) masked.add(i); break; } case 'fibonacci': { const fib = fibonacciIndexSet(n); for (let i = 0; i < n; i++) if (fib.has(i + 1)) masked.add(i); break; } case 'primes': { const primes = primeIndexSet(n); for (let i = 0; i < n; i++) if (primes.has(i + 1)) masked.add(i); break; } case 'wordlen': { const maxLen = clampNumber(s.mask_word_length, 1, 10, 3); for (let i = 0; i < n; i++) if (tokens[i].word.length <= maxLen) masked.add(i); break; } case 'rita': { const RiTa = await loadRiTa(); const cls = RITA_CLASSES.find((c) => c.value === (s.mask_rita_class || 'nouns')) || RITA_CLASSES[0]; for (let i = 0; i < n; i++) { let tags = null; try { tags = RiTa.pos(tokens[i].word); } catch (e) { tags = null; } if (!Array.isArray(tags)) continue; if (tags.some((tag) => typeof tag === 'string' && cls.tags.some((p) => tag.startsWith(p)))) { masked.add(i); } } break; } default: return null; } return masked; } /* Defensive cleanup of streamed diffusion replies: strips zero-width characters, including the U+2060 payloads the server only sends when a request opts into includeSteps (always false for this app). */ export function sanitizeDiffusionOutput(content) { return String(content || '') .replace(/[\u2060\u200b\ufeff]/g, '') .trim(); } /* * One masked-infill call to a diffusion model, mirroring the Re-imagine * plugin: system + user prompt condition the refill, the assistant * message carries the masked text ('…' = holes). */ export async function runDiffusionInfill(apiKey, modelName, modelSettings, userPrompt, assistantContent) { const body = { model: modelName, messages: [ { role: 'system', content: 'Only requested text based on instructions in output - no comments or prefixing' }, { role: 'user', content: userPrompt }, { role: 'assistant', content: assistantContent }, ], stream: true, extra_body: buildModelRequestBody(modelSettings, modelName), }; let content = ''; for await (const chunk of streamCompletion(apiKey, body)) { content += chunk; } return content; } /* Flat sampler settings for buildModelRequestBody from the namespaced per-kind knobs stored by the auto-typist panel. */ export function extractModelSettings(settings, modelName) { const prefix = isDiffusionGemmaModel(modelName) ? DIFFUSIONGEMMA_PARAMS_PREFIX : MDLM_PARAMS_PREFIX; const out = {}; for (const [k, v] of Object.entries(settings || {})) { if (k.startsWith(prefix)) out[k.slice(prefix.length)] = v; } return out; } const DIFFUSION_INFILL_FALLBACK_PROMPT = 'Fill in the masked words naturally to improve the sentence. Output only the completed sentence.'; const MAX_CHUNK_ROUNDS = 16; /* * Diffusion auto-typist core: mask words of `sentenceText` according to * the configured pattern, refill them via the diffusion model — once * for most patterns, once per chunk round for the 'chunk' pattern — and * return the final text (which may equal the input) or null when * nothing was produced. */ export async function runMaskedAutotype(apiKey, modelName, settings, sentenceText) { const s = settings || {}; const pattern = s.mask_pattern || 'random'; const userPrompt = String(s.prompt || '').trim() || DIFFUSION_INFILL_FALLBACK_PROMPT; const modelSettings = extractModelSettings(s, modelName); /* Mask tokens per masked word (the per-word generation budget for the refill). Flanking whitespace stays verbatim — see applyMask. */ const perWord = Math.round( clampNumber(s.mask_tokens_per_word, MASK_TOKENS_PER_WORD.min, MASK_TOKENS_PER_WORD.max, MASK_TOKENS_PER_WORD.default) ); /* 'chunk': go through the sentence, each round masking the next X words of the current (refilled) text. */ if (pattern === 'chunk') { const chunkSize = Math.round(clampNumber(s.mask_chunk_size, 3, 10, 5)); let current = sentenceText; let nextStart = 0; for (let round = 0; round < MAX_CHUNK_ROUNDS; round++) { const tokens = tokenizeWords(current); if (!tokens.length || nextStart >= tokens.length) break; const masked = new Set(); for (let i = nextStart; i < Math.min(nextStart + chunkSize, tokens.length); i++) masked.add(i); nextStart += chunkSize; const maskedText = applyMask(current, tokens, masked, perWord); let result; try { result = await runDiffusionInfill(apiKey, modelName, modelSettings, userPrompt, maskedText); } catch (err) { console.error('Autotype diffusion error (chunk round):', err); break; } const next = sanitizeDiffusionOutput(result); if (!next) break; current = next; } return current; } /* Every other pattern: one masked-infill shot. */ const tokens = tokenizeWords(sentenceText); if (!tokens.length) return null; const masked = await computeMaskIndices(tokens, pattern, s); if (!masked || masked.size === 0) return null; const maskedText = applyMask(sentenceText, tokens, masked, perWord); const result = await runDiffusionInfill(apiKey, modelName, modelSettings, userPrompt, maskedText); const clean = sanitizeDiffusionOutput(result); return clean || null; } export function ensureApiKey() { if (window.litellm_key) return window.litellm_key; const key = window.prompt('Enter your LiteLLM API key:'); if (key) { window.litellm_key = key; return key; } throw new Error('No API key provided'); } export async function* streamCompletion(apiKey, body) { const resp = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(body), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`HTTP ${resp.status}: ${text}`); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith('data: ')) continue; const jsonStr = trimmed.slice(6); if (jsonStr === '[DONE]') continue; try { const chunk = JSON.parse(jsonStr); const content = chunk.choices?.[0]?.delta?.content; if (content != null) yield content; } catch { // ignore parse errors for malformed chunks } } } // trailing buffer if (buffer.trim()) { const trimmed = buffer.trim(); if (trimmed.startsWith('data: ')) { const jsonStr = trimmed.slice(6); if (jsonStr !== '[DONE]') { try { const chunk = JSON.parse(jsonStr); const content = chunk.choices?.[0]?.delta?.content; if (content != null) yield content; } catch { // ignore } } } } } import React, { useCallback } from 'react'; export function SentenceLoggerPlugin({ useContextMenuItem }) { const handler = useCallback((ctx) => { console.log('=== TEXT CONTEXT ==='); console.log('Text :', ctx.text); console.log('Selection :', ctx.text.slice(ctx.selectionStart, ctx.selectionEnd)); console.log('Start :', ctx.selectionStart); console.log('End :', ctx.selectionEnd); alert( `Text: "${ctx.text}"\n\nSelection: "${ctx.text.slice(ctx.selectionStart, ctx.selectionEnd)}"` ); }, []); useContextMenuItem('debug/log-sentence', 'Inspect context…', handler); return null; } export function HighlightParagraphPlugin({ useContextMenuItem }) { const handler = useCallback((ctx) => { if (!ctx.containerElement) return; const el = ctx.containerElement; el.style.backgroundColor = el.style.backgroundColor === 'yellow' ? '' : 'yellow'; }, []); useContextMenuItem('debug/hl-para', 'Toggle paragraph highlight', handler); return null; } import React, { useCallback } from 'react'; import { ensureApiKey, streamCompletion, buildModelRequestBody } from '#App [name="dllm-utils"]'; /* ============================================================ DLLMReimaginePlugin – LLaDA-powered text reimagining ============================================================ */ async function runReimagine(apiKey, settings, assistantContent) { const model = window.activeModel?.name || 'haic/llada2.1-mini-pruned128'; const systemPrompt = 'Only requested text based on instructions in output - no comments or prefixing'; const userPrompt = settings.plugin?.prompt || ''; const messages = [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt?userPrompt:"random interesting text, the text only, no chat, no talk, no preface" }, { role: 'assistant', content: assistantContent }, ]; const body = { model, messages, stream: true, extra_body: buildModelRequestBody(settings.model), }; let content = ''; for await (const chunk of streamCompletion(apiKey, body)) { content += chunk; } return content; } const MAX_SELECTION_TOKENS = 96; function estimateSelectionTokens(text, selectionStart, selectionEnd) { const selected = text.slice(selectionStart, selectionEnd); const words = selected.trim().split(/\s+/).filter((w) => w.length > 0).length; const symbols = (selected.match(/[^a-zA-Z0-9\s]/g) || []).length; return words + symbols; } /* ------------------------------------------------------------ Word-bounding helpers for "around selection" ------------------------------------------------------------ */ function findWordBoundsBackward(text, fromIndex, wordCount) { if (wordCount <= 0) return { start: fromIndex, end: fromIndex }; let end = fromIndex; while (end > 0 && /\s/.test(text[end - 1])) end--; let start = end; let wordsFound = 0; while (start > 0 && wordsFound < wordCount) { while (start > 0 && /\S/.test(text[start - 1])) start--; wordsFound++; if (wordsFound >= wordCount) break; while (start > 0 && /\s/.test(text[start - 1])) start--; } return { start, end }; } function findWordBoundsForward(text, fromIndex, wordCount) { if (wordCount <= 0) return { start: fromIndex, end: fromIndex }; let start = fromIndex; while (start < text.length && /\s/.test(text[start])) start++; let end = start; let wordsFound = 0; while (end < text.length && wordsFound < wordCount) { while (end < text.length && /\S/.test(text[end])) end++; wordsFound++; if (wordsFound >= wordCount) break; while (end < text.length && /\s/.test(text[end])) end++; } return { start, end }; } export function DLLMReimaginePlugin({ useEditor, useContextMenuItem }) { const { openPromptModal } = useEditor(); const executePrompt = useCallback(async (settings, context) => { const apiKey = ensureApiKey(); const { text, selectionStart, selectionEnd } = context; const selectedPart = text.slice(selectionStart, selectionEnd); const wordCount = selectedPart.split(' ').filter((w) => w.length > 0).length; const dots = '…'.repeat(Math.max(1, wordCount)); const before = text.slice(0, selectionStart); const after = text.slice(selectionEnd); const assistantContent = before + dots + after; return runReimagine(apiKey, settings, assistantContent); }, []); const executeAroundPrompt = useCallback(async (settings, context) => { const apiKey = ensureApiKey(); const { text, selectionStart, selectionEnd } = context; const plugin = settings.plugin || {}; const beforeWords = Math.max(0, Math.min(plugin.before ?? 5, 25)); const afterWords = Math.max(0, Math.min(plugin.after ?? 5, 25)); const availableBefore = (text.slice(0, selectionStart).match(/\S+/g) || []).length; const actualBefore = Math.min(beforeWords, availableBefore); const availableAfter = (text.slice(selectionEnd).match(/\S+/g) || []).length; const actualAfter = Math.min(afterWords, availableAfter); const beforeBounds = findWordBoundsBackward(text, selectionStart, actualBefore); const afterBounds = findWordBoundsForward(text, selectionEnd, actualAfter); const prefix = text.slice(0, beforeBounds.start); const beforeGap = text.slice(beforeBounds.end, selectionStart); const selectionText = text.slice(selectionStart, selectionEnd); const afterGap = text.slice(selectionEnd, afterBounds.start); const suffix = text.slice(afterBounds.end); const beforeDots = '…'.repeat(Math.max(0, actualBefore)); const afterDots = '…'.repeat(Math.max(0, actualAfter)); const assistantContent = prefix + beforeDots + beforeGap + selectionText + afterGap + afterDots + suffix; return runReimagine(apiKey, settings, assistantContent); }, []); const executeExpandPrompt = useCallback(async (settings, context) => { const apiKey = ensureApiKey(); const { text, selectionStart, selectionEnd } = context; const plugin = settings.plugin || {}; const before = Math.max(0, Math.min(plugin.before ?? 5, 25)); const after = Math.max(0, Math.min(plugin.after ?? 5, 25)); const beforeDots = '…'.repeat(before); const afterDots = '…'.repeat(after); const prefix = text.slice(0, selectionStart); const selectionText = text.slice(selectionStart, selectionEnd); const suffix = text.slice(selectionEnd); const assistantContent = prefix + beforeDots + selectionText + afterDots + suffix; return runReimagine(apiKey, settings, assistantContent); }, []); const handler = useCallback( (ctx) => { const pluginParams = { contextScope: { name: 'Context', type: 'context-scope', default: 'sentence', hint: 'How much surrounding text is given to the model', values: [ { value: 'sentence', title: 'Sentence', hint: 'The sentence(s) containing the selection' }, { value: 'sibling-sentences', title: 'Sibling sentences', hint: 'Adds the previous and the next sentence' }, { value: 'paragraph', title: 'Paragraph', hint: 'All sentences of the paragraph containing the selection' }, { value: 'sibling-paragraphs', title: 'Sibling paragraphs', hint: 'Adds the previous and the next paragraph' }, { value: 'document', title: 'Document', hint: 'The entire document' }, ], }, prompt: { name: 'Prompt', type: 'text', default: '', hint: 'Optional instructions for the reimagining', } }; openPromptModal({ ...ctx, pluginParams }, executePrompt); }, [openPromptModal, executePrompt] ); const aroundHandler = useCallback( (ctx) => { const pluginParams = { contextScope: { name: 'Context', type: 'context-scope', default: 'sentence', hint: 'How much surrounding text is given to the model', values: [ { value: 'sentence', title: 'Sentence', hint: 'The sentence(s) containing the selection' }, { value: 'sibling-sentences', title: 'Sibling sentences', hint: 'Adds the previous and the next sentence' }, { value: 'paragraph', title: 'Paragraph', hint: 'All sentences of the paragraph containing the selection' }, { value: 'sibling-paragraphs', title: 'Sibling paragraphs', hint: 'Adds the previous and the next paragraph' }, { value: 'document', title: 'Document', hint: 'The entire document' }, ], }, before: { name: 'Before', type: 'range', default: 5, min: 0, max: 25, hint: 'Words before selection to reimagine', }, after: { name: 'After', type: 'range', default: 5, min: 0, max: 25, hint: 'Words after selection to reimagine', }, prompt: { name: 'Prompt', type: 'text', default: '', hint: 'Optional instructions for the reimagining', } }; const inputHighlights = (settings, _context) => { const { text, selectionStart, selectionEnd } = _context; const plugin = settings.plugin || {}; const beforeWords = Math.max(0, Math.min(plugin.before ?? 5, 25)); const afterWords = Math.max(0, Math.min(plugin.after ?? 5, 25)); const availableBefore = (text.slice(0, selectionStart).match(/\S+/g) || []).length; const actualBefore = Math.min(beforeWords, availableBefore); const availableAfter = (text.slice(selectionEnd).match(/\S+/g) || []).length; const actualAfter = Math.min(afterWords, availableAfter); const beforeBounds = findWordBoundsBackward(text, selectionStart, actualBefore); const afterBounds = findWordBoundsForward(text, selectionEnd, actualAfter); const ranges = []; if (beforeBounds.start < beforeBounds.end) ranges.push({ start: beforeBounds.start, end: beforeBounds.end }); if (afterBounds.start < afterBounds.end) ranges.push({ start: afterBounds.start, end: afterBounds.end }); return ranges; }; openPromptModal({ ...ctx, pluginParams, inputHighlights }, executeAroundPrompt); }, [openPromptModal, executeAroundPrompt] ); const expandHandler = useCallback( (ctx) => { const pluginParams = { contextScope: { name: 'Context', type: 'context-scope', default: 'sentence', hint: 'How much surrounding text is given to the model', values: [ { value: 'sentence', title: 'Sentence', hint: 'The sentence(s) containing the selection' }, { value: 'sibling-sentences', title: 'Sibling sentences', hint: 'Adds the previous and the next sentence' }, { value: 'paragraph', title: 'Paragraph', hint: 'All sentences of the paragraph containing the selection' }, { value: 'sibling-paragraphs', title: 'Sibling paragraphs', hint: 'Adds the previous and the next paragraph' }, { value: 'document', title: 'Document', hint: 'The entire document' }, ], }, before: { name: 'Before', type: 'range', default: 5, min: 0, max: 25, hint: 'Number of placeholder chars before selection', }, after: { name: 'After', type: 'range', default: 5, min: 0, max: 25, hint: 'Number of placeholder chars after selection', }, prompt: { name: 'Prompt', type: 'text', default: '', hint: 'Optional instructions for the expansion', } }; openPromptModal({ ...ctx, pluginParams }, executeExpandPrompt); }, [openPromptModal, executeExpandPrompt] ); const selectionTooBigTitle = `Selection is too large (>${MAX_SELECTION_TOKENS} tokens). Select less text.`; useContextMenuItem( 'llm/reimagine', 'Selection itself…', handler, (ctx) => ctx.selectionEnd > ctx.selectionStart, "Re-imagine…", (ctx) => estimateSelectionTokens(ctx.text, ctx.selectionStart, ctx.selectionEnd) > MAX_SELECTION_TOKENS, selectionTooBigTitle ); useContextMenuItem( 'llm/reimagine-around', 'Around selection…', aroundHandler, (ctx) => ctx.selectionEnd > ctx.selectionStart, "Re-Imagine…", (ctx) => estimateSelectionTokens(ctx.text, ctx.selectionStart, ctx.selectionEnd) > MAX_SELECTION_TOKENS, selectionTooBigTitle ); useContextMenuItem( 'expand', 'Expand…', expandHandler, (ctx) => ctx.selectionEnd > ctx.selectionStart, undefined, (ctx) => estimateSelectionTokens(ctx.text, ctx.selectionStart, ctx.selectionEnd) > MAX_SELECTION_TOKENS, selectionTooBigTitle ); return null; } export function AutotypeDebugPlugin({ useEditor, useAutotypePlugin }) { const { addSuggestion } = useEditor(); useAutotypePlugin('autotype/debug', async ({ sentenceText, anchorId }) => { console.log('=== AUTOTYPE DEBUG CONTEXT ==='); console.log('Sentence text :', sentenceText); console.log('Anchor ID :', anchorId); console.log('Callback invoke: addSuggestion({ id, anchorId, originalText, suggestedText })'); console.log('================================'); }, null, 'Debug Logger'); return null; } /* ------------------------------------------------------------ Lowercase plugin – suggests lowercasing sentences with upper-case words ------------------------------------------------------------ */ export function AutotypeLowercasePlugin({ useEditor, useAutotypePlugin }) { const { addSuggestion } = useEditor(); useAutotypePlugin('autotype/lowercase', async ({ sentenceText, anchorId }) => { if (!sentenceText?.trim()) return; // Check if sentence contains any word with an upper-case letter const hasUppercaseWord = /\b\w*[A-Z]\w*\b/.test(sentenceText); if (!hasUppercaseWord) return; const suggested = sentenceText.toLowerCase(); if (suggested === sentenceText) return; addSuggestion({ id: `sugg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, anchorId, originalText: sentenceText, suggestedText: suggested, }); }, null, 'Lowercase Suggester'); return null; } import React, { useMemo } from 'react'; import { ensureApiKey, streamCompletion, isDiffusionGemmaModel, runMaskedAutotype, RITA_CLASSES, MDLM_PARAMS_PREFIX, DIFFUSIONGEMMA_PARAMS_PREFIX, MASK_TOKENS_PER_WORD, } from '#App [name="dllm-utils"]'; /* ============================================================ AutotypeLLMPlugin – LLM-powered sentence rewriting suggestions One plugin, two engines picked by the Model dropdown: - Transformer LLM (default): rewrite the sentence via a chat completion (temp / max_tokens / top_p), as before. - Text-diffusion models: mask words of the sentence following a masking pattern, refill the holes via the diffusion server (like the Re-imagine plugins), suggest the result. ============================================================ */ const TRANSFORMER_MODEL = 'cavi/small'; /* Masking patterns for the diffusion models (semantics in dllm-utils' computeMaskIndices / runMaskedAutotype). */ const MASK_PATTERNS = [ { value: 'random', title: 'Random', hint: 'Mask a random number of randomly chosen words (bounded by Max % masked)' }, { value: 'chunk', title: 'X words at a time', hint: 'Go through the sentence, each round remasking and refilling the next X words' }, { value: 'every_other', title: 'Every other word', hint: 'Mask alternating words in a single pass' }, { value: 'fibonacci', title: 'Fibonacci', hint: 'Mask words at Fibonacci positions (1, 2, 3, 5, 8, 13, …)' }, { value: 'primes', title: 'Primes', hint: 'Mask words at prime positions (2, 3, 5, 7, 11, …)' }, { value: 'wordlen', title: 'Word length', hint: 'Mask all words with at most Max word length characters' }, { value: 'rita', title: 'RiTa.js word class', hint: 'Mask words of one grammatical class, tagged with RiTa.js' }, ]; function humanizeKey(key) { return key.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()); } /* * Built inside the component (NOT at module scope): the model list * comes from window.availableModels, populated by app.jsx, which * evaluates after this module. The returned object must stay * referentially stable across renders, or SetupPanel's value * re-initialization effect (dep on `params`) would loop. */ function createAutotypeParams() { const availableModels = Array.isArray(window.availableModels) ? window.availableModels : []; const diffusionModels = availableModels.map((m) => m.name); const isDiffusionModel = (name) => diffusionModels.includes(name); const params = { chance: { type: 'range', min: 1, max: 100, step: 1, default: 10, hint: 'Chance to run when typing sentences (1–100%). Manual trigger (Ctrl+Space) always runs.', }, model: { name: 'Model', type: 'enum', values: [ { value: TRANSFORMER_MODEL, title: `${TRANSFORMER_MODEL} (transformer)` }, ...diffusionModels.map((name) => ({ value: name, title: name })), ], default: TRANSFORMER_MODEL, hint: 'Model used when the auto-typist fires. Transformer models rewrite the whole sentence; diffusion models refill masked words (see Masking pattern).', }, /* --- Transformer-only settings (the original behavior) --- */ temp: { type: 'range', min: 0, max: 2, step: 0.1, default: 0.7, hint: 'Sampling temperature', showIf: (v) => !isDiffusionModel(v.model), }, max_tokens: { type: 'range', min: 1, max: 512, step: 1, default: 64, hint: 'Maximum tokens to generate', showIf: (v) => !isDiffusionModel(v.model), }, top_p: { type: 'range', min: 0, max: 1, step: 0.05, default: 0.95, hint: 'Nucleus sampling parameter', showIf: (v) => !isDiffusionModel(v.model), }, /* --- Diffusion-only settings --- */ mask_pattern: { name: 'Masking pattern', type: 'enum', values: MASK_PATTERNS, default: 'random', showIf: (v) => isDiffusionModel(v.model), hint: 'Which words of the sentence get masked before the diffusion model refills them', }, mask_tokens_per_word: { name: 'Tokens per word', type: 'range', min: MASK_TOKENS_PER_WORD.min, max: MASK_TOKENS_PER_WORD.max, step: 1, default: MASK_TOKENS_PER_WORD.default, showIf: (v) => isDiffusionModel(v.model), hint: `Mask tokens each masked word becomes (${MASK_TOKENS_PER_WORD.min}–${MASK_TOKENS_PER_WORD.max}): the per-word generation budget for the refill. Whitespace around masked words stays in the text as-is`, }, mask_random_percent: { name: 'Max % masked', type: 'range', min: 5, max: 50, step: 1, default: 25, showIf: (v) => isDiffusionModel(v.model) && (v.mask_pattern || 'random') === 'random', hint: 'Upper bound on the number of masked words, as a percentage of the sentence; the actual number is rolled randomly on every trigger', }, mask_chunk_size: { name: 'Words per round', type: 'range', min: 3, max: 10, step: 1, default: 5, showIf: (v) => isDiffusionModel(v.model) && v.mask_pattern === 'chunk', hint: 'Each round masks the next X words of the sentence and refills them (capped at 16 rounds)', }, mask_word_length: { name: 'Max word length', type: 'range', min: 1, max: 10, step: 1, default: 3, showIf: (v) => isDiffusionModel(v.model) && v.mask_pattern === 'wordlen', hint: 'Mask every word with at most this many characters', }, mask_rita_class: { name: 'Word class', type: 'enum', values: RITA_CLASSES.map((c) => ({ value: c.value, title: c.title, hint: `Mask words RiTa.js tags as ${c.title.toLowerCase()}`, })), default: 'nouns', showIf: (v) => isDiffusionModel(v.model) && v.mask_pattern === 'rita', hint: 'RiTa.js POS class of words to mask (the library loads from CDN on first use)', }, }; /* Sampler knobs of the diffusion models, namespaced per model kind (mdlm.* / gemma.*) so keys shared by both kinds (steps, max_tokens, seed) don't collide. One namespace per kind, shared by all models of that kind. */ const seenKinds = new Set(); for (const m of availableModels) { const isGemma = isDiffusionGemmaModel(m.name); const prefix = isGemma ? DIFFUSIONGEMMA_PARAMS_PREFIX : MDLM_PARAMS_PREFIX; if (seenKinds.has(prefix)) continue; seenKinds.add(prefix); for (const [key, spec] of Object.entries(m.params || {})) { params[prefix + key] = { ...spec, name: spec.name || humanizeKey(key), showIf: (v) => isDiffusionModel(v.model) && (isGemma ? isDiffusionGemmaModel(v.model) : !isDiffusionGemmaModel(v.model)), }; } } /* Shared prompt, last in the panel: guides the transformer rewrite and, when the diffusion model supports it, the masked refill. */ params.prompt = { type: 'text', rows: 3, default: 'Vivid words', hint: 'Instruction sent to the model: the transformer rewrites the sentence by it; diffusion models refill the masked words according to it (if supported)', }; return params; } export function AutotypeLLMPlugin({ useEditor, useAutotypePlugin }) { const { addSuggestion, getAutotypeSettings } = useEditor(); /* Stable param specs (see the note on createAutotypeParams). */ const params = useMemo(() => createAutotypeParams(), []); useAutotypePlugin('autotype/llm', async ({ sentenceText, anchorId, trigger }) => { if (!sentenceText?.trim()) return; const settings = getAutotypeSettings('autotype/llm'); // Only apply chance for auto trigger; manual trigger (Ctrl+Space) always runs if (trigger === 'auto') { const chance = settings.chance ?? 10; const roll = Math.random() * 100; if (roll > chance) return; } const diffusionNames = (Array.isArray(window.availableModels) ? window.availableModels : []) .map((m) => m.name); const savedModel = settings.model || TRANSFORMER_MODEL; const modelName = savedModel === TRANSFORMER_MODEL || diffusionNames.includes(savedModel) ? savedModel : TRANSFORMER_MODEL; /* --- Diffusion path: mask words, refill like Re-imagine --- */ if (modelName !== TRANSFORMER_MODEL) { const apiKey = ensureApiKey(); let suggested = null; try { suggested = await runMaskedAutotype(apiKey, modelName, settings, sentenceText); } catch (err) { console.error('Autotype diffusion error:', err); } suggested = String(suggested || '').trim(); if (!suggested || suggested === sentenceText.trim()) return; addSuggestion({ id: `sugg-at-dllm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, anchorId, originalText: sentenceText, suggestedText: suggested, }); return; } /* --- Transformer path (original behavior) --- */ const userPrompt = settings.prompt ?? 'Rewrite this sentence to be more clear and concise.'; const temp = settings.temp ?? 0.7; const max_tokens = settings.max_tokens ?? 64; const top_p = settings.top_p ?? 0.95; const apiKey = ensureApiKey(); const messages = [ { role: 'system', content: 'You are a helpful writing assistant. Only output the rewritten text, with no extra commentary, quotes, or explanation.' }, { role: 'user', content: `${userPrompt}\n\n"${sentenceText}"` }, ]; const body = { model: modelName, messages, stream: true, temperature: temp, max_tokens, top_p, }; try { let content = ''; for await (const chunk of streamCompletion(apiKey, body)) { content += chunk; } content = content.trim(); if (!content || content === sentenceText) return; addSuggestion({ id: `sugg-at-llm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, anchorId, originalText: sentenceText, suggestedText: content, }); } catch (err) { console.error('Autotype LLM error:', err); } }, params, 'AI Auto-typist'); return null; } html, body { height: 100%; } body { background: grey; padding: 0; margin: 0; } .app-layout { display: flex; height: 100vh; overflow: hidden; } /* iOS-style overlaid scrollbars */ * { scrollbar-width: thin; scrollbar-color: rgba(80,80,80,0.35) transparent; } ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(80, 80, 80, 0.25); border-radius: 3px; transition: background 0.4s ease; } *:hover::-webkit-scrollbar-thumb { background: rgba(80, 80, 80, 0.55); } .prototype-overlay { position: fixed; bottom: 32px; left: -55px; background: rgba(220, 53, 34, 0.92); color: #fff; font-family: system-ui, sans-serif; font-size: 13px; font-weight: 700; text-align: center; letter-spacing: 1.5px; text-transform: uppercase; padding: 10px 70px; transform: rotate(45deg); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3); line-height: 1.3; z-index: 10001; pointer-events: none; white-space: nowrap; &__sub { font-size: 10px; font-weight: 600; letter-spacing: 0.5px; opacity: 0.95; text-transform: none; margin-top: 2px; } }