210 lines
7.2 KiB
TypeScript
210 lines
7.2 KiB
TypeScript
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<AssistantStatus | null>(null)
|
|
const [models, setModels] = useState<AssistantModelRead[]>([])
|
|
const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference)
|
|
const [defaultModel, setDefaultModel] = useState('')
|
|
const [conversation, setConversation] = useState<AssistantConversationState>({ scopeKey, messages: [] })
|
|
const [requestState, setRequestState] = useState<AssistantRequestState>({
|
|
scopeKey,
|
|
requestId: 0,
|
|
loading: false,
|
|
error: null,
|
|
})
|
|
const [loadingModels, setLoadingModels] = useState(false)
|
|
const [modelError, setModelError] = useState<string | null>(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<boolean> => {
|
|
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 }
|
|
}
|