Files
neo/frontend/components/knowledge/SemanticLinksPanel.vue

238 lines
7.9 KiB
Vue

<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import { useApi } from '@/composables/useApi'
type SemanticLink = {
id: string
source_entity_type: string
source_entity_id: string
target_entity_type: string
target_entity_id: string
source_entity_label?: string
target_entity_label?: string
source_entity_name?: string
target_entity_name?: string
link_type: string
status: string
origin: string
confidence?: number
reason?: string
evidence?: any
updated_at?: string
}
interface Props {
objectApiName: string
recordId: string
}
const props = defineProps<Props>()
const { api } = useApi()
const links = ref<SemanticLink[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const activeTab = ref<'all' | 'suggested' | 'approved' | 'rejected' | 'dismissed'>('suggested')
const formatDate = (value?: string) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
const formatConfidence = (value?: number) => {
if (value === undefined || value === null) return '—'
return `${Math.round(value * 100)}%`
}
const getOtherSide = (link: SemanticLink) => {
const isSource =
link.source_entity_type === props.objectApiName &&
link.source_entity_id === props.recordId
return {
entityType: isSource ? link.target_entity_type : link.source_entity_type,
entityId: isSource ? link.target_entity_id : link.source_entity_id,
entityLabel: isSource ? link.target_entity_label : link.source_entity_label,
entityName: isSource ? link.target_entity_name : link.source_entity_name,
}
}
const formatLinkType = (value?: string) => {
if (!value) return 'Related'
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
const parseEvidence = (raw: any) => {
if (!raw) return null
if (typeof raw === 'object') return raw
try {
return JSON.parse(raw)
} catch {
return null
}
}
const fetchLinks = async () => {
if (!props.objectApiName || !props.recordId) return
loading.value = true
error.value = null
try {
const params =
activeTab.value === 'all'
? undefined
: { status: activeTab.value }
const data = await api.get(`/knowledge/semantic/links/${props.objectApiName}/${props.recordId}`, {
params,
})
links.value = Array.isArray(data) ? data : []
} catch (e: any) {
error.value = e.message || 'Failed to load semantic links'
} finally {
loading.value = false
}
}
const reviewLink = async (id: string, status: 'approved' | 'rejected' | 'dismissed') => {
try {
await api.patch(`/knowledge/semantic/links/${id}/review`, { status })
await fetchLinks()
} catch (e: any) {
error.value = e.message || 'Failed to update link'
}
}
const canApprove = (status: string) => status !== 'approved'
const canReject = (status: string) => status !== 'rejected'
const canDismiss = (status: string) => status !== 'dismissed'
watch(
() => [props.objectApiName, props.recordId, activeTab.value],
() => {
fetchLinks()
},
{ immediate: true },
)
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-center justify-between">
<CardTitle>Semantic Links</CardTitle>
<Button variant="ghost" size="sm" @click="fetchLinks">Refresh</Button>
</CardHeader>
<CardContent class="space-y-4">
<Tabs v-model="activeTab" class="space-y-4">
<TabsList>
<TabsTrigger value="suggested">Suggested</TabsTrigger>
<TabsTrigger value="approved">Approved</TabsTrigger>
<TabsTrigger value="rejected">Rejected</TabsTrigger>
<TabsTrigger value="dismissed">Dismissed</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
</TabsList>
<TabsContent :value="activeTab" class="space-y-4">
<div v-if="loading" class="text-sm text-muted-foreground">
Loading links...
</div>
<div v-else-if="error" class="text-sm text-destructive">
{{ error }}
</div>
<div v-else-if="links.length === 0" class="text-sm text-muted-foreground">
No links found.
</div>
<div v-else class="space-y-4">
<div
v-for="link in links"
:key="link.id"
class="rounded-lg border p-4 space-y-3"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-medium">
{{ getOtherSide(link).entityLabel || getOtherSide(link).entityType }} ·
{{ getOtherSide(link).entityName || getOtherSide(link).entityId }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatLinkType(link.link_type) }} {{ link.origin }} {{ formatConfidence(link.confidence) }}
</div>
</div>
<div class="text-xs text-muted-foreground">
Status: <span class="font-medium text-foreground">{{ link.status }}</span>
<span v-if="link.updated_at" class="ml-2">Updated: {{ formatDate(link.updated_at) }}</span>
</div>
<p v-if="link.reason" class="text-sm">{{ link.reason }}</p>
<div v-if="parseEvidence(link.evidence)" class="text-xs text-muted-foreground space-y-2">
<Separator />
<div>
<div class="font-medium text-foreground">Evidence</div>
<p v-if="parseEvidence(link.evidence)?.explanation" class="mt-1 text-foreground">
{{ parseEvidence(link.evidence).explanation }}
</p>
<div v-if="parseEvidence(link.evidence)?.matchedSignals?.length" class="mt-2">
<div>Matched context:</div>
<ul class="list-disc pl-4">
<li
v-for="(signal, idx) in parseEvidence(link.evidence).matchedSignals"
:key="idx"
>
{{ signal }}
</li>
</ul>
</div>
<div v-if="parseEvidence(link.evidence)?.matchedChunks?.length" class="mt-2">
<div>Matched excerpts:</div>
<ul class="list-disc pl-4">
<li
v-for="(match, idx) in parseEvidence(link.evidence).matchedChunks"
:key="idx"
>
{{ match.sourceKind }}: {{ match.text }}
</li>
</ul>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
@click="reviewLink(link.id, 'approved')"
:disabled="!canApprove(link.status)"
>
Approve
</Button>
<Button
size="sm"
variant="outline"
@click="reviewLink(link.id, 'rejected')"
:disabled="!canReject(link.status)"
>
Reject
</Button>
<Button
size="sm"
variant="outline"
@click="reviewLink(link.id, 'dismissed')"
:disabled="!canDismiss(link.status)"
>
Dismiss
</Button>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</template>