import { useEffect, useMemo, useRef, useState } from 'react' import { formatError } from '../lib/formatError' import { assistantApi } from '../services/api/assistant' import type { AssistantChatMessage, AssistantModelRead, AssistantQueryResponse, AssistantStatus, VectorSelectionBBox, } from '../types' export interface GeoAssistantMessage extends AssistantChatMessage { id: string response?: AssistantQueryResponse } interface UseGeoAssistantOptions { selectedProjectId: string | null selectedAreaId: string | null selectionBbox: VectorSelectionBBox | null } const ASSISTANT_MODEL_PREFERENCE_KEY = 'geointel.assistant.model-preference.v1' let assistantMessageSequence = 0 function nextAssistantMessageId(role: AssistantChatMessage['role']): string { assistantMessageSequence += 1 return `${role}-${Date.now()}-${assistantMessageSequence}` } function readStoredPreference(): string { try { return window.localStorage.getItem(ASSISTANT_MODEL_PREFERENCE_KEY) || 'automatic' } catch { return 'automatic' } } function assistantScopeKey( projectId: string | null, areaId: string | null, bbox: VectorSelectionBBox | null, ): string { return JSON.stringify([ projectId, areaId, bbox?.min_x ?? null, bbox?.min_y ?? null, bbox?.max_x ?? null, bbox?.max_y ?? null, bbox?.crs ?? null, ]) } interface AssistantConversationState { scopeKey: string messages: GeoAssistantMessage[] } interface AssistantRequestState { scopeKey: string requestId: number loading: boolean error: string | null } export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) { const scopeKey = assistantScopeKey(selectedProjectId, selectedAreaId, selectionBbox) const activeScopeRef = useRef(scopeKey) const latestRequestIdRef = useRef(0) if (activeScopeRef.current !== scopeKey) { activeScopeRef.current = scopeKey latestRequestIdRef.current += 1 } const [status, setStatus] = useState(null) const [models, setModels] = useState([]) const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference) const [defaultModel, setDefaultModel] = useState('') const [conversation, setConversation] = useState({ scopeKey, messages: [] }) const [requestState, setRequestState] = useState({ scopeKey, requestId: 0, loading: false, error: null, }) const [loadingModels, setLoadingModels] = useState(false) const [modelError, setModelError] = useState(null) const messages = conversation.scopeKey === scopeKey ? conversation.messages : [] const loading = requestState.scopeKey === scopeKey && requestState.loading const queryError = requestState.scopeKey === scopeKey ? requestState.error : null const error = queryError ?? modelError const selectedModel = useMemo(() => { const available = new Set(models.map((model) => model.name)) if (selectedModelChoice !== 'automatic' && available.has(selectedModelChoice)) return selectedModelChoice if (defaultModel && available.has(defaultModel)) return defaultModel return models[0]?.name ?? '' }, [defaultModel, models, selectedModelChoice]) const setSelectedModel = (value: string) => { const valid = value === 'automatic' || models.some((model) => model.name === value) const next = valid ? value : 'automatic' setSelectedModelChoice(next) try { window.localStorage.setItem(ASSISTANT_MODEL_PREFERENCE_KEY, next) } catch { /* voorkeur blijft sessielokaal */ } } const loadModels = async () => { setLoadingModels(true) setModelError(null) try { const currentStatus = await assistantApi.status() setStatus(currentStatus) if (!currentStatus.enabled || !currentStatus.reachable) { setModels([]) setDefaultModel('') return } const result = await assistantApi.models() setModels(result.items) const fallback = result.default_model && result.items.some((model) => model.name === result.default_model) ? result.default_model : result.items[0]?.name ?? '' setDefaultModel(fallback) setSelectedModelChoice((current) => current === 'automatic' || result.items.some((model) => model.name === current) ? current : 'automatic') } catch (requestError) { setStatus(null) setModels([]) setDefaultModel('') setModelError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.')) } finally { setLoadingModels(false) } } useEffect(() => { void loadModels() }, []) useEffect(() => { setConversation({ scopeKey, messages: [] }) setRequestState({ scopeKey, requestId: latestRequestIdRef.current, loading: false, error: null, }) }, [scopeKey]) const ask = async (question: string): Promise => { const trimmed = question.trim() if (!selectedProjectId || !trimmed || !selectedModel) return false const requestId = latestRequestIdRef.current + 1 latestRequestIdRef.current = requestId const requestScopeKey = scopeKey const userMessage: GeoAssistantMessage = { id: nextAssistantMessageId('user'), role: 'user', content: trimmed } setConversation((current) => ({ scopeKey: requestScopeKey, messages: [...(current.scopeKey === requestScopeKey ? current.messages : []), userMessage], })) setRequestState({ scopeKey: requestScopeKey, requestId, loading: true, error: null }) const isLatestRequest = () => ( latestRequestIdRef.current === requestId && activeScopeRef.current === requestScopeKey ) try { const history = messages.slice(-6).map(({ role, content }) => ({ role, content })) const result = await assistantApi.query(selectedProjectId, { question: trimmed, model: selectedModel, bbox: selectionBbox, area_id: selectedAreaId, history, }) if (!isLatestRequest()) return false setConversation((current) => current.scopeKey === requestScopeKey ? { scopeKey: requestScopeKey, messages: [...current.messages, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }], } : current) return true } catch (requestError) { if (!isLatestRequest()) return false setRequestState({ scopeKey: requestScopeKey, requestId, loading: false, error: formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'), }) return false } finally { if (isLatestRequest()) { setRequestState((current) => current.scopeKey === requestScopeKey && current.requestId === requestId ? { ...current, loading: false } : current) } } } const clear = () => { latestRequestIdRef.current += 1 setConversation({ scopeKey, messages: [] }) setRequestState({ scopeKey, requestId: latestRequestIdRef.current, loading: false, error: null, }) } return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel } }