238 lines
7.9 KiB
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>
|