149 lines
3.9 KiB
Vue
149 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
import { MESSAGE_LIST_PIN_THRESHOLD_PX } from '../lib/constants'
|
|
import { formatMessage, messages } from '../lib/messages'
|
|
import type { AssistantTurn, ChatTurn } from '../lib/models'
|
|
import AssistantMessage from './AssistantMessage.vue'
|
|
import EmptyState from './EmptyState.vue'
|
|
import UserMessage from './UserMessage.vue'
|
|
|
|
const props = defineProps<{
|
|
turns: ChatTurn[]
|
|
suggestions: readonly string[]
|
|
canSave: boolean
|
|
}>()
|
|
defineEmits<{
|
|
pick: [suggestion: string]
|
|
save: [turnId: string]
|
|
retry: [query: string]
|
|
}>()
|
|
|
|
const list = ref<HTMLElement | null>(null)
|
|
const pinned = ref(true)
|
|
function turnFingerprint(turn: ChatTurn): string {
|
|
if (turn.role === 'user') return turn.id
|
|
return `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`
|
|
}
|
|
|
|
function findLatestAssistant(): AssistantTurn | undefined {
|
|
for (let index = props.turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = props.turns[index]
|
|
if (turn?.role === 'assistant') return turn
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const fingerprint = computed(() => props.turns.map(turnFingerprint).join('|'))
|
|
const streaming = computed(() =>
|
|
props.turns.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
|
|
)
|
|
const latestAssistant = computed(findLatestAssistant)
|
|
const announcement = computed(() => {
|
|
const turn = latestAssistant.value
|
|
if (!turn) return ''
|
|
if (turn.status === 'error') return messages.messageListRequestFailed
|
|
if (turn.status === 'done') {
|
|
const trackCount = turn.completion?.track_count ?? turn.tracks.length
|
|
return formatMessage('messageListTracksReady', { count: trackCount })
|
|
}
|
|
const warning = turn.warnings.at(-1)
|
|
if (warning) {
|
|
return formatMessage('messageListWarning', {
|
|
count: turn.warnings.length,
|
|
message: warning.message,
|
|
})
|
|
}
|
|
if (turn.requestId === null) return messages.messageListReadingRequest
|
|
if (turn.tracks.length === 0) return messages.messageListCheckingCandidates
|
|
return formatMessage('messageListVerifiedTracksReceived', { count: turn.tracks.length })
|
|
})
|
|
|
|
function onScroll(): void {
|
|
const element = list.value
|
|
if (!element) return
|
|
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
|
|
pinned.value = distanceFromBottom < MESSAGE_LIST_PIN_THRESHOLD_PX
|
|
}
|
|
|
|
async function scrollToLatest(): Promise<void> {
|
|
if (!pinned.value) return
|
|
await nextTick()
|
|
requestAnimationFrame(() => {
|
|
if (list.value) list.value.scrollTop = list.value.scrollHeight
|
|
})
|
|
}
|
|
|
|
watch(fingerprint, scrollToLatest, { flush: 'post' })
|
|
watch(streaming, (value) => {
|
|
if (value) pinned.value = true
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<main
|
|
ref="list"
|
|
class="list"
|
|
:aria-label="messages.messageListConversationLabel"
|
|
@scroll="onScroll"
|
|
>
|
|
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
|
|
{{ announcement }}
|
|
</p>
|
|
<div class="column">
|
|
<EmptyState
|
|
v-if="turns.length === 0"
|
|
:suggestions="suggestions"
|
|
@pick="$emit('pick', $event)"
|
|
/>
|
|
<div v-for="turn in turns" :key="turn.id" class="turn">
|
|
<UserMessage v-if="turn.role === 'user'" :text="turn.text" />
|
|
<AssistantMessage
|
|
v-else
|
|
:turn="turn"
|
|
:can-save="canSave"
|
|
@save="$emit('save', turn.id)"
|
|
@retry="$emit('retry', $event)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.list {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
padding: 32px var(--gutter) 28px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s-9);
|
|
max-width: var(--measure);
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.turn {
|
|
animation: message-in var(--dur-message) ease both;
|
|
}
|
|
|
|
@keyframes message-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 560px) {
|
|
.list {
|
|
padding-top: 24px;
|
|
}
|
|
}
|
|
</style>
|