Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced hardcoded "Agenda" labels with dynamic ULTICAL_APP_NAME in various components for consistency. - Introduced new AiUsageSection and CompteAiUsageSection components to track AI usage and costs. - Updated settings and metadata to reflect changes in AI cost policies and usage limits. - Enhanced user interface elements for better accessibility and user experience across admin settings.
221 lines
6.7 KiB
TypeScript
221 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 { AiSpendBar } from "@/components/ai/ai-spend-bar"
|
|
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 ? <AiSpendBar quota={quota} compact className="max-w-[140px]" /> : 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'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>
|
|
)
|
|
}
|