Bug fix: - ChatView.vue onMounted now skips fetchConversation when the conversation is already loaded in the store (same guard that the convId watcher uses). This prevents duplicate assistant messages when navigating from the dashboard inline chat to /chat/:id after streaming completes. Generation timing: - logging.py: add log_generation() — persists per-generation timing breakdown to app_logs (category=usage, action=generation) including model, total_ms, intent_ms, ttft_ms, generation_ms, and per-tool timings. Queryable via existing admin log viewer. - generation_task.py: collect wall-clock timestamps at every pipeline stage: intent classification, per-tool execution (both intent-routed and native), time-to-first-token (measured from generation start to first content chunk), LLM streaming round duration. Logs via log_generation() and includes timing in the SSE 'done' event payload. - types/chat.ts: add GenerationTiming interface; add optional timing field to Message. - chat.ts: capture timing from done event and attach to assistant message. - ChatMessage.vue: show timing footer on assistant messages with breakdown: "⏱ 4.2s total · first token 0.8s · analyzed 0.3s · created event 0.4s · generated 3.5s". Visible this session; persisted to app_logs for cross-session benchmarking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
243 lines
5.8 KiB
Vue
243 lines
5.8 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from "vue";
|
|
import { renderMarkdown } from "@/utils/markdown";
|
|
import { useSettingsStore } from "@/stores/settings";
|
|
import ToolCallCard from "@/components/ToolCallCard.vue";
|
|
import type { GenerationTiming, Message } from "@/types/chat";
|
|
|
|
const settingsStore = useSettingsStore();
|
|
|
|
const props = defineProps<{
|
|
message: Message;
|
|
isStreaming?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
saveAsNote: [messageId: number];
|
|
}>();
|
|
|
|
const rendered = computed(() => renderMarkdown(props.message.content));
|
|
|
|
const formattedTime = computed(() => {
|
|
if (!props.message.created_at) return "";
|
|
const date = new Date(props.message.created_at);
|
|
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
});
|
|
|
|
const roleLabel = computed(() => {
|
|
switch (props.message.role) {
|
|
case "user":
|
|
return "You";
|
|
case "assistant":
|
|
return settingsStore.assistantName;
|
|
default:
|
|
return props.message.role;
|
|
}
|
|
});
|
|
|
|
function formatMs(ms: number | null | undefined): string {
|
|
if (ms == null) return "";
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
const timingParts = computed((): string[] => {
|
|
const t = props.message.timing;
|
|
if (!t) return [];
|
|
const parts: string[] = [];
|
|
if (t.total_ms != null) parts.push(`${formatMs(t.total_ms)} total`);
|
|
if (t.ttft_ms != null) parts.push(`first token ${formatMs(t.ttft_ms)}`);
|
|
if (t.intent_ms != null) parts.push(`analyzed ${formatMs(t.intent_ms)}`);
|
|
for (const tool of t.tools ?? []) {
|
|
parts.push(`${tool.name.replace(/_/g, " ")} ${formatMs(tool.ms)}`);
|
|
}
|
|
if (t.generation_ms != null) parts.push(`generated ${formatMs(t.generation_ms)}`);
|
|
return parts;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="chat-message" :class="`role-${message.role}`">
|
|
<div class="message-group">
|
|
<div class="message-bubble">
|
|
<div class="message-header">
|
|
<span class="role-label">{{ roleLabel }}</span>
|
|
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
|
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
|
Save as Note
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="message-content prose" v-html="rendered"></div>
|
|
<div v-if="message.tool_calls?.length" class="tool-calls">
|
|
<ToolCallCard
|
|
v-for="(tc, i) in message.tool_calls"
|
|
:key="i"
|
|
:tool-call="tc"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="message.context_note_id"
|
|
class="context-badge"
|
|
>
|
|
<router-link :to="`/notes/${message.context_note_id}`">
|
|
Note #{{ message.context_note_id }}
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
<span v-if="formattedTime" class="message-timestamp">{{ formattedTime }}</span>
|
|
<div v-if="timingParts.length" class="message-timing">
|
|
<span class="timing-icon">⏱</span>
|
|
<span v-for="(part, i) in timingParts" :key="i" class="timing-part">
|
|
<span v-if="i > 0" class="timing-sep">·</span>{{ part }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.chat-message {
|
|
display: flex;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.role-user {
|
|
justify-content: flex-end;
|
|
}
|
|
.role-assistant {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.message-group {
|
|
max-width: 80%;
|
|
}
|
|
|
|
.message-bubble {
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 16px;
|
|
}
|
|
|
|
.role-user .message-bubble {
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
.role-assistant .message-bubble {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-bottom-left-radius: 4px;
|
|
}
|
|
|
|
.message-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.role-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.role-user .role-label {
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
.role-assistant .role-label {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.message-content {
|
|
font-size: 0.95rem;
|
|
line-height: 1.55;
|
|
word-break: break-word;
|
|
}
|
|
.message-content :deep(p:last-child) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* User bubble content overrides for readability on primary bg */
|
|
.role-user .message-content :deep(a) {
|
|
color: rgba(255, 255, 255, 0.9);
|
|
text-decoration: underline;
|
|
}
|
|
.role-user .message-content :deep(code) {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
color: #fff;
|
|
}
|
|
.role-user .message-content :deep(pre) {
|
|
background: rgba(0, 0, 0, 0.2);
|
|
}
|
|
.role-user .message-content :deep(blockquote) {
|
|
border-left-color: rgba(255, 255, 255, 0.4);
|
|
color: rgba(255, 255, 255, 0.85);
|
|
}
|
|
|
|
.message-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
.btn-save {
|
|
font-size: 0.7rem;
|
|
padding: 0.1rem 0.4rem;
|
|
background: transparent;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
.btn-save:hover {
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border-color: var(--color-primary);
|
|
}
|
|
.tool-calls {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
margin-top: 0.4rem;
|
|
}
|
|
.context-badge {
|
|
margin-top: 0.4rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
.context-badge a {
|
|
color: var(--color-primary);
|
|
text-decoration: none;
|
|
}
|
|
.role-user .context-badge a {
|
|
color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
.context-badge a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.message-timestamp {
|
|
display: block;
|
|
font-size: 0.7rem;
|
|
color: var(--color-text-muted);
|
|
margin-top: 0.15rem;
|
|
padding: 0 0.5rem;
|
|
}
|
|
.message-timing {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
padding: 0.1rem 0.5rem 0;
|
|
font-size: 0.68rem;
|
|
color: var(--color-text-muted);
|
|
opacity: 0.7;
|
|
}
|
|
.timing-icon {
|
|
font-size: 0.65rem;
|
|
}
|
|
.timing-part {
|
|
white-space: nowrap;
|
|
}
|
|
.timing-sep {
|
|
margin-right: 0.2rem;
|
|
opacity: 0.5;
|
|
}
|
|
</style>
|