Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
105 changes: 105 additions & 0 deletions layers/nuxi/app/components/agent/AgentChatBody.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<script setup lang="ts">
import type { UIMessage } from 'ai'
import type { AgentChatHandle } from '../../composables/eve/types'

const input = defineModel<string>('input', { required: true })

const props = withDefaults(defineProps<{
chat: AgentChatHandle
chatId: string
prompt: { pasteAttachments: TextPasteAttachment[], canSubmit: boolean }
usage?: { used: number, remaining: number, limit: number }
rateLimitReached?: boolean
compact?: boolean
showFaqEmpty?: boolean
faqQuestions?: FaqCategory[]
showActions?: boolean
showUserTimestamps?: boolean
spacingOffset?: number
getVote: (messageId: string) => boolean | null
variant?: 'subtle' | 'naked'
size?: 'sm' | 'md'
promptClass?: string
promptUi?: Record<string, string>
submitDisabled?: boolean
rateLimitVariant?: 'sticky' | 'footer'
loginHintBar?: boolean
}>(), {
compact: false,
showFaqEmpty: false,
faqQuestions: () => [],
showActions: true,
showUserTimestamps: false,
spacingOffset: 0,
variant: 'subtle',
rateLimitVariant: 'sticky',
loginHintBar: false
})

const emit = defineEmits<{
submit: []
paste: [event: ClipboardEvent]
removeAttachment: [index: number]
restoreAttachment: [index: number]
vote: [message: UIMessage, isUpvoted: boolean]
askQuestion: [question: string]
}>()

const { loggedIn } = useUserSession()
const showLoginHint = computed(() => props.loginHintBar || !loggedIn.value)

const promptRef = useTemplateRef('promptRef')
defineExpose({
focus: () => promptRef.value?.focus()
})
</script>

<template>
<AgentChatMessages
:chat="chat"
:chat-id="chatId"
:compact="compact"
:show-faq-empty="showFaqEmpty"
:faq-questions="faqQuestions"
:show-actions="showActions"
:show-user-timestamps="showUserTimestamps"
:spacing-offset="spacingOffset"
:get-vote="getVote"
:class="compact ? 'flex flex-col gap-4' : 'flex-1 pt-4 pb-4 sm:pb-6'"
@ask-question="emit('askQuestion', $event)"
@vote="(message, isUpvoted) => emit('vote', message, isUpvoted)"
/>

<AgentRateLimitBanner v-if="rateLimitReached" :variant="rateLimitVariant" />

<div
v-else
class="flex flex-col gap-1.5"
:class="rateLimitVariant === 'sticky' ? 'sticky bottom-0 z-10 bg-default' : ''"
>
<slot name="before-prompt" />

<AgentLoginHint v-if="showLoginHint" :bar="loginHintBar" />

<AgentChatPrompt
ref="promptRef"
v-model="input"
v-bind="prompt"
:chat="chat"
:usage="usage"
:variant="variant"
:size="size"
:submit-disabled="submitDisabled"
:class="promptClass"
:ui="promptUi"
@submit="emit('submit')"
@paste="emit('paste', $event)"
@remove-attachment="emit('removeAttachment', $event)"
@restore-attachment="emit('restoreAttachment', $event)"
>
<template v-if="$slots['prompt-footer-left']" #footer-left>
<slot name="prompt-footer-left" />
</template>
</AgentChatPrompt>
</div>
</template>
18 changes: 12 additions & 6 deletions layers/nuxi/app/components/agent/AgentPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ const {
const { chatList } = useChats()
const { loggedIn } = useUserSession()

type ActiveChat = { id: string, messages: UIMessage[] }
const active = shallowRef<ActiveChat>({ id: crypto.randomUUID(), messages: [] })
type ActiveChat = { id: string, messages: UIMessage[], state: ChatEveState | null }
const active = shallowRef<ActiveChat>({ id: crypto.randomUUID(), messages: [], state: null })
const chatId = computed(() => active.value.id)
const initialMessages = computed(() => active.value.messages)
const initialState = computed(() => active.value.state)
let loadToken = 0

function startNewChat() {
loadToken++
active.value = { id: crypto.randomUUID(), messages: [] }
active.value = { id: crypto.randomUUID(), messages: [], state: null }
}

async function setActiveChat(id: string) {
Expand All @@ -31,10 +32,10 @@ async function setActiveChat(id: string) {
try {
const data = await $fetch<ChatDetail>(`/api/chats/${id}`)
if (token !== loadToken) return
active.value = { id, messages: toUIMessages(data.messages ?? []) }
active.value = { id, messages: dbRowsToUIMessages(data.messages ?? []), state: data.state ?? null }
} catch {
if (token === loadToken) {
active.value = { id: crypto.randomUUID(), messages: [] }
active.value = { id: crypto.randomUUID(), messages: [], state: null }
}
}
}
Expand Down Expand Up @@ -116,6 +117,11 @@ defineShortcuts({
</UTooltip>
</template>

<AgentPanelChat :key="chatId" :chat-id="chatId" :initial-messages="initialMessages" />
<AgentPanelChat
:key="chatId"
:chat-id="chatId"
:initial-messages="initialMessages"
:initial-state="initialState"
/>
</AgentPanelShell>
</template>
164 changes: 86 additions & 78 deletions layers/nuxi/app/components/agent/AgentPanelChat.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { UIMessage } from 'ai'
import { useAgentChatSession } from '../../composables/useAgentChatSession'

const props = defineProps<{
chatId: string
initialMessages?: UIMessage[]
initialState?: ChatEveState | null
}>()

const {
Expand All @@ -29,12 +31,18 @@ const {
getVote,
vote,
askQuestion,
send
} = useAgentChat({
onSubmit,
handlePaste,
removeAttachment,
restoreToInput
} = useAgentChatSession({
chatId: props.chatId,
initialMessages: props.initialMessages,
initialState: props.initialState,
source: 'prompt',
withPageContext: 'when-enabled',
consumePendingPrompt,
consumePendingMessageParts: () => null,
onFinish: () => {
if (loggedIn.value) refreshChats()
}
Expand All @@ -47,8 +55,6 @@ watch(() => chat.status, (status) => {
else nuxiMood.value = 'idle'
}, { immediate: true })

// The docked sidebar keeps its content mounted when closed, so `autofocus`
// only fires once. Refocus the prompt each time the panel reopens.
const promptRef = useTemplateRef('promptRef')
watch(isOpen, (value) => {
if (value) {
Expand All @@ -57,11 +63,6 @@ watch(isOpen, (value) => {
})
}
})

onMounted(() => {
const pendingPrompt = consumePendingPrompt()
if (pendingPrompt) send(pendingPrompt)
})
</script>

<template>
Expand All @@ -81,85 +82,92 @@ onMounted(() => {
</div>

<div class="flex w-full shrink-0 flex-col border-y border-default">
<AgentLoginHint v-if="!loggedIn" bar />

<Transition
enter-active-class="transition duration-200 ease-out"
leave-active-class="transition duration-150 ease-in"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="pageContextEnabled"
class="flex items-center gap-2 border-b border-default px-4 py-2.5"
>
<div class="min-w-0 flex-1 flex items-center gap-1.5 text-xs">
<span class="shrink-0 text-dimmed">Agent is using</span>
<img
src="/icon.png"
alt=""
class="size-3.5 shrink-0 rounded-sm opacity-90"
>
<span
class="min-w-0 truncate font-medium text-highlighted"
:title="currentPage ?? undefined"
>{{ contextPathLabel }}</span>
</div>
<UTooltip text="Stop using this page as context" :kbds="['tab']">
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
class="shrink-0 text-dimmed hover:text-default -me-1"
aria-label="Stop using this page as context"
@click="pageContextDismissed = true"
/>
</UTooltip>
</div>
</Transition>

<AgentRateLimitBanner v-if="rateLimitReached" variant="footer" />

<AgentChatPrompt
v-else
ref="promptRef"
v-model="input"
v-bind="prompt"
:chat="chat"
:usage="usage"
variant="naked"
size="sm"
:submit-disabled="chat.status === 'ready' && !(prompt.canSubmit ?? input.trim())"
:ui="{ root: 'px-4 pb-2', body: 'px-0', base: 'px-0 rounded-none', header: 'px-0 pt-2 pb-0 gap-1.5 flex flex-wrap items-start', footer: 'px-0 items-baseline' }"
>
<template #footer-left>
<div class="flex items-center gap-2 text-xs text-dimmed">
<UTooltip v-if="currentPage && !pageContextEnabled" text="Use page as context" :kbds="['tab']">
<div v-else class="flex flex-col gap-1.5">
<Transition
enter-active-class="transition duration-200 ease-out"
leave-active-class="transition duration-150 ease-in"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="pageContextEnabled"
class="flex items-center gap-2 border-b border-default px-4 py-2.5"
>
<div class="min-w-0 flex-1 flex items-center gap-1.5 text-xs">
<span class="shrink-0 text-dimmed">Agent is using</span>
<img src="/icon.png" alt="" class="size-3.5 shrink-0 rounded-sm opacity-90">
<span
class="min-w-0 truncate font-medium text-highlighted"
:title="currentPage ?? undefined"
>{{ contextPathLabel }}</span>
</div>
<UTooltip text="Stop using this page as context" :kbds="['tab']">
<UButton
type="button"
icon="i-lucide-file-plus"
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
class="-my-1 -mx-1.5 text-dimmed hover:text-default"
aria-label="Use page as context"
@click.stop="pageContextDismissed = false"
class="shrink-0 text-dimmed hover:text-default -me-1"
aria-label="Stop using this page as context"
@click="pageContextDismissed = true"
/>
</UTooltip>
</div>
</Transition>

<USeparator v-if="currentPage && !pageContextEnabled" orientation="vertical" class="h-4" />
<AgentLoginHint bar />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

<UTooltip v-if="usage" text="Daily messages remaining">
<span :class="usage.remaining <= 5 ? 'text-warning' : ''">
{{ usage.remaining }}/{{ usage.limit }}
</span>
</UTooltip>
</div>
</template>
</AgentChatPrompt>
<AgentChatPrompt
ref="promptRef"
v-model="input"
v-bind="prompt"
:chat="chat"
:usage="usage"
variant="naked"
size="sm"
:submit-disabled="chat.status === 'ready' && !(prompt.canSubmit ?? input.trim())"
:ui="{
root: 'px-4 pb-2',
body: 'px-0',
base: 'px-0 rounded-none',
header: 'px-0 pt-2 pb-0 gap-1.5 flex flex-wrap items-start',
footer: 'px-0 items-baseline'
}"
@submit="onSubmit"
@paste="handlePaste"
@remove-attachment="removeAttachment"
@restore-attachment="restoreToInput"
>
<template #footer-left>
<div class="flex items-center gap-2 text-xs text-dimmed">
<UTooltip v-if="currentPage && !pageContextEnabled" text="Use page as context" :kbds="['tab']">
<UButton
type="button"
icon="i-lucide-file-plus"
color="neutral"
variant="ghost"
size="sm"
class="-my-1 -mx-1.5 text-dimmed hover:text-default"
aria-label="Use page as context"
@click.stop="pageContextDismissed = false"
/>
</UTooltip>

<USeparator v-if="currentPage && !pageContextEnabled" orientation="vertical" class="h-4" />

<UTooltip v-if="usage" text="Daily messages remaining">
<span :class="usage.remaining <= 5 ? 'text-warning' : ''">
{{ usage.remaining }}/{{ usage.limit }}
</span>
</UTooltip>
</div>
</template>
</AgentChatPrompt>
</div>
</div>
</div>
</template>
2 changes: 1 addition & 1 deletion layers/nuxi/app/composables/eve/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { UIMessage } from 'ai'
import type { EveMessage } from 'eve/vue'
import type { AgentChatStatus } from './types'

export function toUIMessages(messages: readonly EveMessage[]): UIMessage[] {
export function eveMessagesToUIMessages(messages: readonly EveMessage[]): UIMessage[] {
return [...messages] as UIMessage[]
}

Expand Down
Loading