Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions admin/slices/chat/components/chat/detail/MetaCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { IChatSession } from '#chat/stores/chat';

defineProps<{ session: IChatSession }>();
const showTools = defineModel<boolean>('showTools', { required: true });
</script>

<template>
<div class="flex flex-col gap-2.5 rounded-xl border border-border/70 bg-card px-4 py-3.5">
<div class="flex items-center justify-between gap-3 text-[12.5px]">
<span class="text-muted-foreground/70">Messages</span>
<span class="font-medium">
{{ session.messageCount }} · {{ session.userMessageCount }} from user
</span>
</div>
<div class="flex items-center justify-between gap-3 text-[12.5px]">
<span class="text-muted-foreground/70">Last activity</span>
<span class="font-medium">
{{ session.lastMessageAt ? formatDateTime(session.lastMessageAt) : '—' }}
</span>
</div>
<div class="flex items-center justify-between gap-3 text-[12.5px]">
<span class="text-muted-foreground/70">Session</span>
<span class="truncate font-mono text-[11.5px] text-foreground/80">
{{ session.sessionKey }}
</span>
</div>

<div class="h-px bg-border/50" />

<label class="flex cursor-pointer items-center gap-2 text-[12.5px] text-foreground/80">
<button
type="button"
role="switch"
:aria-checked="showTools"
class="relative h-[18px] w-8 shrink-0 rounded-full transition-colors"
:class="showTools ? 'bg-primary' : 'bg-muted-foreground/25'"
@click="showTools = !showTools"
>
<span
class="absolute top-0.5 size-3.5 rounded-full bg-white shadow-sm transition-[left]"
:class="showTools ? 'left-[15px]' : 'left-0.5'"
/>
</button>
Tool events
</label>
</div>
</template>
34 changes: 34 additions & 0 deletions admin/slices/chat/components/chat/detail/NavMap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { INavMapItem } from '#chat/utils/transcript';

defineProps<{ items: INavMapItem[] }>();
defineEmits<{ jump: [id: string] }>();
</script>

<template>
<div
v-if="items.length"
class="flex flex-col gap-2 rounded-xl border border-border/70 bg-card px-4 pb-2 pt-3.5"
>
<span
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70"
>
Navigation
</span>
<div class="-mx-2 flex max-h-64 flex-col overflow-y-auto">
<button
v-for="mi in items"
:key="mi.id"
type="button"
class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
@click="$emit('jump', mi.id)"
>
<span
class="size-[7px] shrink-0 rounded-full"
:class="mi.isUser ? 'bg-primary' : 'bg-muted-foreground/30'"
/>
<span class="truncate text-xs text-foreground/80">{{ mi.snippet }}</span>
</button>
</div>
</div>
</template>
143 changes: 97 additions & 46 deletions admin/slices/chat/components/chat/detail/Provider.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<script setup lang="ts">
import { IconArrowLeft } from '@tabler/icons-vue';
import {
groupTranscript,
snippet,
type INavMapItem,
} from '#chat/utils/transcript';

const props = defineProps<{ id: string }>();
const store = useChatStore();
Expand All @@ -12,64 +17,110 @@ const { messages, hasMore, loading, showTools, scroller, loadLatest, loadOlder }
useChatTranscript(props.id);
const { feedbackByMsg, rate } = useChatFeedback(props.id);

const who = computed(
() => session.value?.title || session.value?.externalUserId || '—',
);

const grouped = computed(() => groupTranscript(messages.value));

const navItems = computed<INavMapItem[]>(() =>
messages.value
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ id: m.id, isUser: m.role === 'user', snippet: snippet(m.text) }))
.filter((mi) => mi.snippet.length > 0),
);

function onJump(id: string) {
scroller.value
?.querySelector(`[data-msg-id="${id}"]`)
?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}

function onExport(format: 'json' | 'markdown' | 'csv') {
void store.exportChat(props.id, format);
}
</script>

<template>
<div class="mx-auto flex max-w-3xl flex-col gap-4">
<NuxtLink
to="/chats"
class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<IconArrowLeft class="size-4" /> Chats
</NuxtLink>

<ChatDetailSummaryCard
v-if="session"
:session="session"
@updated="session = $event"
/>

<!-- Controls -->
<div class="flex items-center justify-between">
<label class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Checkbox v-model="showTools" /> Show tool events
</label>
<div class="flex items-center gap-1.5">
<span class="text-xs text-muted-foreground">Export</span>
<Button size="sm" variant="outline" @click="onExport('json')">JSON</Button>
<Button size="sm" variant="outline" @click="onExport('markdown')">MD</Button>
<Button size="sm" variant="outline" @click="onExport('csv')">CSV</Button>
<Button size="sm" variant="ghost" :disabled="loading" @click="loadLatest">
Refresh
</Button>
<div class="flex w-full flex-col gap-4">
<!-- Slim header -->
<div class="flex items-center gap-3 border-b pb-3">
<NuxtLink
to="/chats"
class="inline-flex shrink-0 items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<IconArrowLeft class="size-4" /> Chats
</NuxtLink>
<div class="h-[18px] w-px bg-border" />
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="truncate text-[15px] font-semibold tracking-tight">{{ who }}</span>
<Badge v-if="session" variant="secondary" class="capitalize">
{{ session.channel }}
</Badge>
<Badge v-if="session?.archived" variant="outline">archived</Badge>
</div>
<Button size="sm" variant="outline" :disabled="loading" @click="loadLatest">
Refresh
</Button>
</div>

<!-- Transcript -->
<div
ref="scroller"
class="flex max-h-[65vh] flex-col gap-3 overflow-y-auto rounded-md border bg-card p-4"
>
<div v-if="hasMore" class="flex justify-center">
<Button size="sm" variant="outline" :disabled="loading" @click="loadOlder">
{{ loading ? 'Loading…' : 'Load older' }}
</Button>
</div>
<div class="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
<!-- Feed -->
<div
ref="scroller"
class="flex h-[calc(100vh-10.5rem)] min-h-0 flex-col gap-4 overflow-y-auto pr-1"
>
<div v-if="hasMore" class="flex justify-center">
<Button size="sm" variant="outline" :disabled="loading" @click="loadOlder">
{{ loading ? 'Loading…' : 'Load older' }}
</Button>
</div>

<div
v-if="!messages.length && !loading"
class="py-16 text-center text-sm text-muted-foreground"
>
No messages in this session.
</div>

<div v-if="!messages.length && !loading" class="py-10 text-center text-sm text-muted-foreground">
No messages in this session.
<template v-for="item in grouped" :key="item.key">
<!-- Standalone tool events (no assistant reply after them) -->
<div v-if="item.message === null" class="pl-9">
<ChatMessageToolEvents :tools="item.tools" class="max-w-[82%]" />
</div>
<div v-else :data-msg-id="item.message.id" class="scroll-mt-2">
<ChatMessageBubble
:message="item.message"
:tools="item.tools"
:rating="feedbackByMsg[item.message.id] ?? null"
@rate="(r: 1 | -1) => rate(item.message!.id, r)"
/>
</div>
</template>
</div>

<ChatMessageBubble
v-for="m in messages"
:key="m.id"
:message="m"
:rating="feedbackByMsg[m.id] ?? null"
@rate="(r: 1 | -1) => rate(m.id, r)"
/>
<!-- Right rail -->
<div
class="flex flex-col gap-3.5 lg:max-h-[calc(100vh-10.5rem)] lg:overflow-y-auto"
>
<ChatDetailMetaCard
v-if="session"
:session="session"
v-model:show-tools="showTools"
/>
<ChatDetailSummaryCard
v-if="session"
:session="session"
@updated="session = $event"
/>
<ChatDetailNavMap :items="navItems" @jump="onJump" />
<div class="flex items-center gap-1.5 px-1">
<span class="mr-1 text-xs text-muted-foreground">Export</span>
<Button size="sm" variant="outline" @click="onExport('json')">JSON</Button>
<Button size="sm" variant="outline" @click="onExport('markdown')">MD</Button>
<Button size="sm" variant="outline" @click="onExport('csv')">CSV</Button>
</div>
</div>
</div>
</div>
</template>
106 changes: 46 additions & 60 deletions admin/slices/chat/components/chat/detail/SummaryCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,72 +28,58 @@ const sentimentVariant: Record<string, 'default' | 'secondary' | 'outline' | 'de
negative: 'destructive',
mixed: 'secondary',
};

const who = computed(
() => props.session.title || props.session.externalUserId || '—',
);
</script>

<template>
<div class="rounded-md border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span class="text-lg font-semibold">{{ who }}</span>
<Badge variant="secondary" class="capitalize">{{ session.channel }}</Badge>
<Badge v-if="session.archived" variant="outline">archived</Badge>
<div class="flex flex-col gap-2 rounded-xl border border-border/70 bg-card px-4 py-3.5">
<div class="flex items-center justify-between gap-2">
<span
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70"
>
Summary
</span>
<button
type="button"
class="rounded-[7px] bg-muted px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-muted/70 disabled:opacity-50"
:disabled="summarizing"
@click="onSummarize"
>
{{ summarizing ? 'Summarizing…' : session.summary ? 'Re-summarize' : 'Summarize' }}
</button>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ session.messageCount }} messages · {{ session.userMessageCount }} from user</span>
<span>Last activity {{ session.lastMessageAt ? formatDateTime(session.lastMessageAt) : '—' }}</span>
<span class="font-mono">{{ session.sessionKey }}</span>

<div v-if="session.insights" class="flex flex-wrap items-center gap-1.5">
<Badge
:variant="sentimentVariant[session.insights.sentiment] ?? 'secondary'"
class="capitalize"
>
{{ session.insights.sentiment }}
</Badge>
<Badge variant="outline" class="capitalize">
{{ session.insights.resolved ? 'resolved' : 'unresolved' }}
</Badge>
<Badge variant="outline" class="capitalize">
{{ session.insights.language }}
</Badge>
</div>
<!-- LLM summary + insights -->
<div class="mt-3 rounded bg-muted/40 p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Summary &amp; insights</span>
<!-- sentiment / resolved / language sit by the header, apart from topics -->
<template v-if="session.insights">
<Badge
:variant="sentimentVariant[session.insights.sentiment] ?? 'secondary'"
class="capitalize"
>
{{ session.insights.sentiment }}
</Badge>
<Badge variant="outline" class="capitalize">
{{ session.insights.resolved ? 'resolved' : 'unresolved' }}
</Badge>
<Badge variant="outline" class="capitalize">
{{ session.insights.language }}
</Badge>
</template>
</div>
<Button size="sm" variant="outline" :disabled="summarizing" @click="onSummarize">
{{ summarizing ? 'Summarizing…' : session.summary ? 'Re-summarize' : 'Summarize' }}
</Button>
</div>
<p v-if="insightError" class="mt-2 text-sm text-destructive">
{{ insightError }}
</p>
<p v-if="session.summary" class="mt-2 text-sm text-muted-foreground">
{{ session.summary }}
</p>
<p v-else class="mt-2 text-sm text-muted-foreground">
No summary yet — click Summarize to generate one.
</p>
<!-- Topic tags only -->
<div
v-if="session.insights?.topics?.length"
class="mt-2 flex flex-wrap items-center gap-1.5"

<p v-if="insightError" class="text-xs text-destructive">{{ insightError }}</p>
<p v-if="session.summary" class="text-[12.5px] leading-relaxed text-muted-foreground">
{{ session.summary }}
</p>
<p v-else class="text-[12.5px] leading-relaxed text-muted-foreground/70">
No summary yet — click Summarize to generate one.
</p>

<div v-if="session.insights?.topics?.length" class="flex flex-wrap items-center gap-1.5">
<Badge
v-for="topic in session.insights.topics"
:key="topic"
variant="outline"
class="capitalize"
>
<Badge
v-for="topic in session.insights.topics"
:key="topic"
variant="outline"
class="capitalize"
>
{{ topic }}
</Badge>
</div>
{{ topic }}
</Badge>
</div>
</div>
</template>
Loading
Loading