feat: add source-grounded evolution and Ollama assistant
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react'
|
||||
import { useGeoAssistant } from '../../hooks/useGeoAssistant'
|
||||
import type { VectorSelectionBBox } from '../../types'
|
||||
|
||||
interface GeoAssistantPanelProps {
|
||||
selectedProjectId: string | null
|
||||
selectedAreaId: string | null
|
||||
selectedAreaName: string | null
|
||||
selectionBbox: VectorSelectionBBox | null
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'Vat de belangrijkste gebiedsmetingen samen.',
|
||||
'Hoe evolueerden bevolking en bosoppervlakte?',
|
||||
'Welke gegevens ontbreken nog voor een volledige wateranalyse?',
|
||||
'Welke officiële bronnen zijn voor dit gebied beschikbaar?',
|
||||
]
|
||||
|
||||
export function GeoAssistantPanel({
|
||||
selectedProjectId,
|
||||
selectedAreaId,
|
||||
selectedAreaName,
|
||||
selectionBbox,
|
||||
}: GeoAssistantPanelProps): JSX.Element {
|
||||
const [question, setQuestion] = useState('')
|
||||
const {
|
||||
status,
|
||||
models,
|
||||
selectedModel,
|
||||
messages,
|
||||
loading,
|
||||
loadingModels,
|
||||
error,
|
||||
loadModels,
|
||||
ask,
|
||||
clear,
|
||||
setSelectedModel,
|
||||
} = useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox })
|
||||
|
||||
const submitQuestion = async (value: string) => {
|
||||
if (await ask(value)) setQuestion('')
|
||||
}
|
||||
const scopeLabel = selectedAreaId
|
||||
? selectedAreaName ?? 'Volledig geselecteerd gebied'
|
||||
: selectionBbox
|
||||
? 'Getekende kaartselectie'
|
||||
: selectedAreaName ?? 'Volledig werkgebied'
|
||||
const ready = Boolean(selectedProjectId && status?.reachable && selectedModel)
|
||||
|
||||
return (
|
||||
<section className="workspace-panel geo-assistant-panel" data-testid="geo-assistant-panel">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<span className="section-kicker">Lokale AI · Ollama</span>
|
||||
<h2>Vraag GeoIntel</h2>
|
||||
<p className="muted">Stel vragen over de gemeten kaartgegevens en officiële tijdreeksen van het actieve gebied.</p>
|
||||
</div>
|
||||
<span className={status?.reachable ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{status?.reachable ? 'Lokaal verbonden' : loadingModels ? 'Verbinden…' : 'Niet bereikbaar'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="assistant-context-strip">
|
||||
<div>
|
||||
<span>Context</span>
|
||||
<strong>{scopeLabel}</strong>
|
||||
</div>
|
||||
<label>
|
||||
<span>Ollama-model</span>
|
||||
<select value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)} disabled={loadingModels || models.length === 0}>
|
||||
{models.length === 0 ? <option value="">Geen model beschikbaar</option> : null}
|
||||
{models.map((model) => (
|
||||
<option value={model.name} key={model.name}>
|
||||
{model.name}{model.parameter_size ? ` · ${model.parameter_size}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="secondary-action" onClick={() => void loadModels()} disabled={loadingModels}>
|
||||
Verbinding vernieuwen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!selectedProjectId ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen werkgebied actief.</strong>
|
||||
<p>Open eerst de regionale werkruimte of een project.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{status && !status.reachable ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Ollama is niet bereikbaar.</strong>
|
||||
<p>{status.limitation_message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="assistant-suggestions" aria-label="Voorbeeldvragen">
|
||||
{SUGGESTIONS.map((suggestion) => (
|
||||
<button type="button" key={suggestion} onClick={() => void submitQuestion(suggestion)} disabled={!ready || loading}>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="assistant-conversation" aria-live="polite">
|
||||
{messages.length === 0 ? (
|
||||
<div className="assistant-empty-state">
|
||||
<strong>Begin bij een concrete gebiedsvraag</strong>
|
||||
<p>GeoIntel stuurt alleen samengevatte, persistente GIS-metingen en broninformatie naar het lokale model.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<article className={`assistant-message assistant-message-${message.role}`} key={message.id}>
|
||||
<span>{message.role === 'user' ? 'Jij' : 'GeoIntel'}</span>
|
||||
<p>{message.content}</p>
|
||||
{message.response ? (
|
||||
<details>
|
||||
<summary>Gebruikte gegevens</summary>
|
||||
<div className="assistant-evidence-grid">
|
||||
<span>{message.response.context_metrics.length} metriek</span>
|
||||
<span>{message.response.temporal_series.length} tijdreeksen</span>
|
||||
<span>{message.response.source_dataset_ids.length} brondatasets</span>
|
||||
<span>{message.response.scope_label}</span>
|
||||
</div>
|
||||
{message.response.warnings.map((warning) => <p className="geo-data-notice" key={warning}>{warning}</p>)}
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{loading ? (
|
||||
<div className="assistant-thinking" role="status">
|
||||
<span />
|
||||
<strong>Lokale gegevens worden samengevat en beantwoord…</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
<form
|
||||
className="assistant-composer"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitQuestion(question)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="geo-assistant-question">Vraag over het actieve gebied</label>
|
||||
<textarea
|
||||
id="geo-assistant-question"
|
||||
value={question}
|
||||
onChange={(event) => setQuestion(event.target.value)}
|
||||
placeholder="Bijvoorbeeld: hoeveel bos verdween er sinds 2013?"
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
disabled={!ready || loading}
|
||||
/>
|
||||
<div>
|
||||
<p>Antwoorden zijn lokaal gegenereerd. Controleer beslissingen altijd tegen de vermelde brondata.</p>
|
||||
<button type="button" className="secondary-action" onClick={clear} disabled={messages.length === 0 || loading}>Wis gesprek</button>
|
||||
<button type="submit" className="primary-action" disabled={!ready || loading || question.trim().length < 2}>Stel vraag</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user