WIP - initial UI for comments and semantic links
This commit is contained in:
181
frontend/components/knowledge/RecordCommentsPanel.vue
Normal file
181
frontend/components/knowledge/RecordCommentsPanel.vue
Normal 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>
|
||||
220
frontend/components/knowledge/SemanticLinksPanel.vue
Normal file
220
frontend/components/knowledge/SemanticLinksPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user