ultisuite-client/components/ai/docs-ai-panel.tsx
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

224 lines
6.7 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { Editor } from "@tiptap/react"
import { Sparkles, X, GripVertical } from "lucide-react"
import { Button } from "@/components/ui/button"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import {
docsContextFromSnapshot,
docsSystemPromptExtra,
snapshotDocsEditor,
} from "@/lib/ai/docs-context"
import {
applyDocsAction,
extractDocsApplyFromMarkdown,
parseDocsApplyPayload,
} from "@/lib/ai/docs-apply"
import type { AiPostMessage } from "@/lib/ai/chat-context"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
import {
DOCS_AI_PANEL_MIN_WIDTH_PX,
useDocsAiPanelStore,
} from "@/lib/ai/use-docs-ai-panel"
import { cn } from "@/lib/utils"
export function DocsAiPanel({
editor,
documentPath,
documentTitle,
sourcePath,
editable,
}: {
editor: Editor | null
documentPath: string
documentTitle: string
sourcePath?: string
editable: boolean
}) {
const open = useDocsAiPanelStore((s) => s.open)
const widthPx = useDocsAiPanelStore((s) => s.widthPx)
const setWidthPx = useDocsAiPanelStore((s) => s.setWidthPx)
const closePanel = useDocsAiPanelStore((s) => s.closePanel)
const { data: config } = useAiConfig()
const { data: quota } = useAiQuota(open && (config?.enabled ?? false))
const [contextTick, setContextTick] = useState(0)
const resizeRef = useRef<{ startX: number; startW: number } | null>(null)
useEffect(() => {
if (!editor || !open) return
const bump = () => setContextTick((n) => n + 1)
editor.on("selectionUpdate", bump)
editor.on("update", bump)
return () => {
editor.off("selectionUpdate", bump)
editor.off("update", bump)
}
}, [editor, open])
const context = useMemo(() => {
if (!editor || !open) {
return {
app: "docs" as const,
temporary: true,
drivePath: documentPath,
documentTitle,
sourcePath,
}
}
const snap = snapshotDocsEditor(editor, {
path: documentPath,
title: documentTitle,
sourcePath,
})
return docsContextFromSnapshot(snap, true)
}, [editor, open, documentPath, documentTitle, sourcePath, contextTick])
const handleApplyFromParent = useCallback(
(payload: unknown) => {
if (!editor) return
const cmd = parseDocsApplyPayload(payload)
if (cmd) applyDocsAction(editor, cmd)
},
[editor]
)
useEffect(() => {
if (!open || !editor) return
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
const data = event.data as AiPostMessage & {
type?: string
payload?: unknown
text?: string
}
if (data?.type === "ULTI_DOCS_APPLY" && data.payload) {
handleApplyFromParent(data.payload)
}
if (data?.type === "ULTI_ASSISTANT_TEXT" && typeof data.text === "string") {
const cmd = extractDocsApplyFromMarkdown(data.text)
if (cmd) applyDocsAction(editor, cmd)
}
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [open, editor, handleApplyFromParent])
useEffect(() => {
if (!open || !editor) return
;(window as Window & { __ultiDocsApply?: (p: unknown) => void }).__ultiDocsApply =
handleApplyFromParent
return () => {
delete (window as Window & { __ultiDocsApply?: (p: unknown) => void }).__ultiDocsApply
}
}, [open, editor, handleApplyFromParent])
const onResizePointerDown = (e: React.PointerEvent) => {
e.preventDefault()
resizeRef.current = { startX: e.clientX, startW: widthPx }
const onMove = (ev: PointerEvent) => {
const st = resizeRef.current
if (!st) return
const delta = st.startX - ev.clientX
setWidthPx(st.startW + delta)
}
const onUp = () => {
resizeRef.current = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
if (!config?.enabled || !open) return null
return (
<div
className="docs-ai-panel relative flex h-full shrink-0 flex-col border-l border-[#dadce0] bg-white dark:border-border dark:bg-background"
style={{ width: widthPx }}
data-docs-ai-panel
>
<button
type="button"
aria-label="Redimensionner le panneau UltiAI"
className="absolute left-0 top-0 z-10 flex h-full w-1.5 cursor-col-resize items-center justify-center hover:bg-[#e8eaed] dark:hover:bg-muted"
onPointerDown={onResizePointerDown}
>
<GripVertical className="pointer-events-none h-4 w-4 text-muted-foreground opacity-0 hover:opacity-100" />
</button>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
<Sparkles className="h-4 w-4 shrink-0 text-[#1a73e8]" />
<span className="truncate">UltiAI</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{quota ? (
<span className="text-[10px] text-muted-foreground">
{quota.requests_remaining} req.
</span>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={closePanel}
aria-label="Fermer UltiAI"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{!editable ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
Mode lecture l&apos;IA peut analyser le document mais pas le modifier.
</p>
) : null}
<div className="min-h-0 flex-1">
<AiChatIframe
publicPath={config.public_path}
context={{
...context,
systemPromptExtra: docsSystemPromptExtra(context),
}}
className={cn("h-full w-full border-0")}
/>
</div>
</div>
)
}
export function DocsAiPanelToggle({
disabled,
className,
}: {
disabled?: boolean
className?: string
}) {
const open = useDocsAiPanelStore((s) => s.open)
const toggle = useDocsAiPanelStore((s) => s.toggle)
const { data: config } = useAiConfig()
if (!config?.enabled) return null
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-9 shrink-0 rounded-full",
open && "bg-[#e8f0fe] text-[#1a73e8] dark:bg-muted",
className
)}
disabled={disabled}
onClick={toggle}
aria-label="UltiAI"
title="UltiAI"
>
<Sparkles className="size-4" />
</Button>
)
}