WIP - initial UI for comments and semantic links

This commit is contained in:
Francisco Gaona
2026-04-11 22:14:24 +02:00
parent dc18b08a3a
commit 12b0a0881e
5 changed files with 437 additions and 3 deletions

View File

@@ -52,11 +52,11 @@ exports.up = async function (knex) {
await knex.schema.createTable('semantic_links', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('source_entity_type').notNullable();
table.string('source_entity_type', 100).notNullable();
table.uuid('source_entity_id').notNullable();
table.string('target_entity_type').notNullable();
table.string('target_entity_type', 100).notNullable();
table.uuid('target_entity_id').notNullable();
table.string('link_type').notNullable().defaultTo('related_to');
table.string('link_type', 100).notNullable().defaultTo('related_to');
table.string('status').notNullable().defaultTo('suggested');
table.string('origin').notNullable().defaultTo('semantic');
table.decimal('confidence', 5, 4).notNullable().defaultTo(0);

View File

@@ -0,0 +1,181 @@
<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 { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import { useApi } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
type CommentRecord = {
id: string
content: string
author_user_id: string
created_at: string
updated_at: string
}
interface Props {
objectApiName: string
recordId: string
}
const props = defineProps<Props>()
const { api } = useApi()
const { user } = useAuth()
const comments = ref<CommentRecord[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const newComment = ref('')
const saving = ref(false)
const editingId = ref<string | null>(null)
const editContent = ref('')
const isOwner = (comment: CommentRecord) => comment.author_user_id === user.value?.id
const formatDate = (value?: string) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
const canSubmit = computed(() => newComment.value.trim().length > 0 && !saving.value)
const canSaveEdit = computed(() => editContent.value.trim().length > 0 && !saving.value)
const fetchComments = async () => {
if (!props.objectApiName || !props.recordId) return
loading.value = true
error.value = null
try {
const data = await api.get(`/knowledge/comments/${props.objectApiName}/${props.recordId}`)
comments.value = Array.isArray(data) ? data : []
} catch (e: any) {
error.value = e.message || 'Failed to load comments'
} finally {
loading.value = false
}
}
const addComment = async () => {
if (!canSubmit.value) return
saving.value = true
try {
await api.post('/knowledge/comments', {
parentObjectApiName: props.objectApiName,
parentRecordId: props.recordId,
content: newComment.value.trim(),
})
newComment.value = ''
await fetchComments()
} catch (e: any) {
error.value = e.message || 'Failed to add comment'
} finally {
saving.value = false
}
}
const startEdit = (comment: CommentRecord) => {
editingId.value = comment.id
editContent.value = comment.content
}
const cancelEdit = () => {
editingId.value = null
editContent.value = ''
}
const saveEdit = async () => {
if (!editingId.value || !canSaveEdit.value) return
saving.value = true
try {
await api.patch(`/knowledge/comments/${editingId.value}`, {
content: editContent.value.trim(),
})
editingId.value = null
editContent.value = ''
await fetchComments()
} catch (e: any) {
error.value = e.message || 'Failed to update comment'
} finally {
saving.value = false
}
}
const deleteComment = async (comment: CommentRecord) => {
if (!confirm('Delete this comment?')) return
saving.value = true
try {
await api.delete(`/knowledge/comments/${comment.id}`)
await fetchComments()
} catch (e: any) {
error.value = e.message || 'Failed to delete comment'
} finally {
saving.value = false
}
}
watch(
() => [props.objectApiName, props.recordId],
() => {
fetchComments()
},
{ immediate: true },
)
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Comments</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-3">
<Textarea
v-model="newComment"
placeholder="Add a comment..."
:disabled="saving"
class="min-h-[96px]"
/>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground" v-if="error">{{ error }}</p>
<Button size="sm" :disabled="!canSubmit" @click="addComment">
Add Comment
</Button>
</div>
</div>
<Separator />
<div v-if="loading" class="text-sm text-muted-foreground">Loading comments...</div>
<div v-else-if="comments.length === 0" class="text-sm text-muted-foreground">
No comments yet.
</div>
<div v-else class="space-y-4">
<div v-for="comment in comments" :key="comment.id" class="rounded-lg border p-4 space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs text-muted-foreground">
<span>Author: {{ comment.author_user_id }}</span>
<span class="mx-2"></span>
<span>{{ formatDate(comment.created_at) }}</span>
</div>
<div class="flex items-center gap-2" v-if="isOwner(comment)">
<Button variant="ghost" size="sm" @click="startEdit(comment)">Edit</Button>
<Button variant="ghost" size="sm" @click="deleteComment(comment)">Delete</Button>
</div>
</div>
<div v-if="editingId === comment.id" class="space-y-2">
<Textarea v-model="editContent" :disabled="saving" class="min-h-[80px]" />
<div class="flex items-center gap-2">
<Button size="sm" :disabled="!canSaveEdit" @click="saveEdit">Save</Button>
<Button variant="ghost" size="sm" @click="cancelEdit">Cancel</Button>
</div>
</div>
<p v-else class="text-sm whitespace-pre-line">{{ comment.content }}</p>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,220 @@
<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
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,
}
}
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).entityType }} · {{ getOtherSide(link).entityId }}
</div>
<div class="text-xs text-muted-foreground">
{{ 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>
<div v-if="parseEvidence(link.evidence)?.sourceSignals?.length">
<div class="mt-1">Source signals:</div>
<ul class="list-disc pl-4">
<li
v-for="(signal, idx) in parseEvidence(link.evidence).sourceSignals"
:key="idx"
>
{{ signal.sourceKind }}: {{ signal.text }}
</li>
</ul>
</div>
<div v-if="parseEvidence(link.evidence)?.matchedChunks?.length" class="mt-2">
<div>Matched:</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>

View File

@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import RelatedList from '@/components/RelatedList.vue'
import RecordCommentsPanel from '@/components/knowledge/RecordCommentsPanel.vue'
import SemanticLinksPanel from '@/components/knowledge/SemanticLinksPanel.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
@@ -167,6 +169,18 @@ const getFieldsBySection = (section: FieldSection) => {
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
<!-- Knowledge Panels -->
<div v-if="data?.id && config?.objectApiName" class="space-y-6">
<RecordCommentsPanel
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
<SemanticLinksPanel
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
</div>
</div>
</template>

View File

@@ -7,6 +7,8 @@ import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
import RelatedList from '@/components/RelatedList.vue'
import RecordSharing from '@/components/RecordSharing.vue'
import RecordCommentsPanel from '@/components/knowledge/RecordCommentsPanel.vue'
import SemanticLinksPanel from '@/components/knowledge/SemanticLinksPanel.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
@@ -170,6 +172,9 @@ const visibleRelatedLists = computed<RelatedListConfig[]>(() => {
<TabsTrigger v-if="showSharing && data.id" value="sharing">
Sharing
</TabsTrigger>
<TabsTrigger v-if="data.id && config.objectApiName" value="knowledge">
Knowledge
</TabsTrigger>
</TabsList>
<!-- Details Tab -->
@@ -277,6 +282,20 @@ const visibleRelatedLists = computed<RelatedListConfig[]>(() => {
</CardContent>
</Card>
</TabsContent>
<!-- Knowledge Tab -->
<TabsContent value="knowledge" class="space-y-6">
<RecordCommentsPanel
v-if="data.id && config.objectApiName"
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
<SemanticLinksPanel
v-if="data.id && config.objectApiName"
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
</TabsContent>
</Tabs>
</div>
</template>