+ {t( + 'llm_admin_description', + 'Configure the AI assistant for your Overleaf instance. Set up the API connection, choose available models, and customize the system prompt.' + )} +
+${safeCodeHtml}${safeCodeHtml}`
+ }
+ return r
+ }, [])
+
+ const renderedHtml = useMemo(() => {
+ try {
+ return marked.parse(result || 'No result yet.', { renderer })
+ } catch {
+ return escapeHtml(result || 'No result yet.')
+ }
+ }, [result, renderer])
+
+ // Copy button delegation
+ useEffect(() => {
+ const root = wrapRef.current
+ if (!root) return
+ const handler = (e: MouseEvent) => {
+ const target = e.target as HTMLElement
+ const btn = target.closest
+ ? (target.closest('.llm-copy-latex') as HTMLElement | null)
+ : null
+ if (!btn) return
+ const b64 = btn.getAttribute('data-code')
+ if (!b64) return
+ const code = base64DecodeUnicode(b64)
+ ; (async () => {
+ try {
+ await navigator.clipboard.writeText(code)
+ const prev = btn.innerText
+ btn.innerText = '✓'
+ setTimeout(() => {
+ btn.innerText = prev
+ }, 900)
+ } catch {
+ btn.innerText = '✕'
+ setTimeout(() => {
+ btn.innerText = '📋'
+ }, 900)
+ }
+ })()
+ }
+ root.addEventListener('click', handler)
+ return () => root.removeEventListener('click', handler)
+ }, [])
+
+ useImperativeHandle(ref, () => ({
+ show: (view: EditorView) => {
+ viewRef.current = view
+ const sel = view.state.selection.main
+ if (sel.empty) return
+
+ const wrapRect = wrapRef.current?.getBoundingClientRect()
+ const editorRect = view.dom.getBoundingClientRect()
+ const coordsFrom = view.coordsAtPos(sel.from)
+ const coordsTo = view.coordsAtPos(sel.to)
+ const toLocal = (c: { left: number; top: number }) => ({
+ x: wrapRect ? c.left - wrapRect.left : c.left,
+ y: wrapRect ? c.top - wrapRect.top : c.top,
+ })
+
+ let aTop = 0,
+ aLeft = 0
+ if (coordsFrom && coordsTo) {
+ const top = Math.min(coordsFrom.top, coordsTo.top)
+ const bottom = Math.max(coordsFrom.bottom, coordsTo.bottom)
+ const right = Math.max(coordsFrom.right, coordsTo.right)
+ const midY = top + (bottom - top) / 2
+ aTop = toLocal({ left: 0, top: midY }).y - 14
+ aLeft = toLocal({ left: right, top: 0 }).x - 14
+ } else {
+ aTop =
+ editorRect.top +
+ editorRect.height / 2 -
+ (wrapRect?.top ?? 0) -
+ 14
+ aLeft = editorRect.right - (wrapRect?.left ?? 0) - 44
+ }
+
+ const containerW = wrapRect?.width ?? window.innerWidth
+ const containerH = wrapRect?.height ?? window.innerHeight
+ const clampedTop = Math.round(
+ Math.max(8, Math.min(containerH - 44, aTop))
+ )
+ const clampedLeft = Math.round(
+ Math.max(8, Math.min(containerW - 44, aLeft))
+ )
+
+ const width = Math.max(320, Math.min(560, containerW - 24))
+ const panelTop = Math.round(
+ Math.max(
+ 12,
+ Math.min(
+ (wrapRect ? editorRect.top - wrapRect.top : 0) + 52,
+ containerH - 260 - 12
+ )
+ )
+ )
+ const panelLeft = Math.round(
+ Math.max(
+ 12,
+ Math.min(
+ containerW - width - 12,
+ (wrapRect ? editorRect.left - wrapRect.left : 0) +
+ editorRect.width / 2 -
+ width / 2
+ )
+ )
+ )
+
+ setPanelRect({ top: panelTop, left: panelLeft, width })
+ setAnchorPos({ top: clampedTop, left: clampedLeft })
+ setSelectionText(view.state.sliceDoc(sel.from, sel.to))
+ setSelectionRange({ from: sel.from, to: sel.to })
+ setAnchorShown(true)
+ setPanelMode('hidden')
+ document.dispatchEvent(
+ new CustomEvent('llm-toolbar-active', { detail: { active: true } })
+ )
+ },
+ hide: () => {
+ setAnchorShown(false)
+ setPanelMode('hidden')
+ setSubmenu(null)
+ document.dispatchEvent(
+ new CustomEvent('llm-toolbar-active', { detail: { active: false } })
+ )
+ },
+ openMenu: () => {
+ setAnchorShown(true)
+ setPanelMode('menu')
+ },
+ getSelectedText: () => selectionText,
+ }))
+
+ useEffect(() => {
+ if (panelMode !== 'hidden') {
+ requestAnimationFrame(() =>
+ inputRef.current?.focus({ preventScroll: true } as any)
+ )
+ }
+ }, [panelMode])
+
+ const onInputKeyDown = (e: React.KeyboardEvent