openpencil/src/components/ChatPanel.vue
Danila Poyarkov db68b71ae1 refactor(chat): extract submission and attachment state
- Move send orchestration, cancellation, Vision analysis, and optimistic history into a submission composable\n- Move composer image and node draft ownership into an attachment composable\n- Replace positional submit arguments with a named ChatSubmission payload
2026-09-04 11:16:32 +03:00

281 lines
9.4 KiB
Vue

<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { refAutoReset, useClipboard } from '@vueuse/core'
import { computed, markRaw, shallowRef, nextTick, ref, watch } from 'vue'
import { getACPDebugText, clearACPDebugLog, hasACPDebugEntries } from '@/app/ai/acp/transport'
import { copyChatLog } from '@/app/ai/debug'
import { clearVisibleMessageText } from '@/app/ai/chat/presentation'
import { useChatSubmission } from '@/app/ai/chat/submission/use'
import { clearMessageAttachments } from '@/app/ai/attachment/presentation/store'
import { clearToolLogEntries, didHitStepLimit } from '@/app/ai/tools'
import { activeTab } from '@/app/tabs'
import { getActiveEditorStore } from '@/app/editor/active-store'
import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import AppButton from '@/components/ui/AppButton.vue'
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
import { useAIChat } from '@/app/ai/chat/use'
import { toast } from '@/app/shell/ui'
import { openSettingsDialog } from '@/app/settings/dialog'
import { useI18n } from '@open-pencil/vue'
import { useNotificationMessages } from '@/app/i18n/notifications'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
const IS_DEV = import.meta.env.DEV
const { isConfigured, ensureChat, resetChat, chatFailure, clearChatFailure } = useAIChat()
const { copy } = useClipboard()
const { ai } = useI18n()
const notifications = useNotificationMessages()
const chat = shallowRef<Chat<UIMessage> | null>(null)
const submission = useChatSubmission({
chat,
ensureChat,
clearFailure: clearChatFailure,
getEditor: getActiveEditorStore,
messages: computed(() => ({
requestFailed: ai.value.chatRequestFailed,
visionUnavailable: ai.value.visionModelUnavailable
})),
reportError: toast.error,
openModelSettings: () => openSettingsDialog('ai')
})
void ensureChat()
.then((c) => {
if (c) chat.value = markRaw(c)
return undefined
})
.catch((error: unknown) => {
toast.error(
notifications.value.chatInitializationFailed({
error: error instanceof Error ? error.message : String(error)
})
)
})
const messagesEnd = ref<HTMLDivElement>()
const debugCopied = refAutoReset(false, 1500)
const acpLogCopied = refAutoReset(false, 1500)
const messages = computed(() => chat.value?.messages ?? [])
const failureMessage = computed(() => {
switch (chatFailure.value?.reason) {
case 'insufficient-credit':
return ai.value.chatInsufficientCredit
case 'output-limit':
return ai.value.chatOutputLimit
case 'request-failed':
return ai.value.chatRequestFailed
default:
return null
}
})
const status = computed(() => chat.value?.status ?? 'ready')
function isStreamingMessage(message: UIMessage, index: number): boolean {
return (
message.role === 'assistant' &&
index === messages.value.length - 1 &&
(status.value === 'submitted' || status.value === 'streaming')
)
}
const isThinking = computed(() => {
const s = status.value
if (s !== 'submitted' && s !== 'streaming') return false
if (messages.value.length === 0) return true
const last = messages.value[messages.value.length - 1]
if (last.role !== 'assistant') return true
const parts = last.parts
if (parts.length === 0) return true
const lastPart = parts[parts.length - 1] as JSONObject
if (lastPart.type === 'step-start') return true
if ('toolCallId' in lastPart && lastPart.state === 'output-available') return true
if ('toolCallId' in lastPart && lastPart.state === 'output-error') return true
return s === 'submitted'
})
const showContinue = computed(() => {
if (status.value !== 'ready') return false
if (messages.value.length === 0) return false
const last = messages.value[messages.value.length - 1]
return last.role === 'assistant' && didHitStepLimit()
})
function scrollToBottom() {
nextTick(() => {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
watch(messages, scrollToBottom, { deep: true })
watch(
() => chatFailure.value?.reason,
(reason) => {
if (!reason) return
toast.error(failureMessage.value ?? ai.value.chatRequestFailed)
}
)
watch(
() => activeTab.value?.id,
async () => {
submission.cancel()
clearMessageAttachments()
clearVisibleMessageText()
const nextChat = await ensureChat()
chat.value = nextChat ? markRaw(nextChat) : null
}
)
function handleStop() {
submission.stop()
}
async function handleCopyDebug() {
await copyChatLog(messages.value, chatFailure.value)
debugCopied.value = true
}
async function handleCopyACPLog() {
const text = getACPDebugText()
if (!text) return
await copy(text)
acpLogCopied.value = true
}
function handleClearChat() {
submission.cancel()
clearChatFailure()
clearMessageAttachments()
clearVisibleMessageText()
chat.value = null
void resetChat().catch((error: unknown) => {
console.error('Chat reset error:', error)
})
clearToolLogEntries()
clearACPDebugLog()
}
</script>
<template>
<div data-test-id="chat-panel" class="flex min-w-0 flex-1 flex-col overflow-hidden select-text">
<ProviderSetup v-if="!isConfigured" />
<template v-else>
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
<AppPlaceholder
v-if="messages.length === 0"
data-test-id="chat-empty-state"
:label="ai.describeCreateOrChange"
:ui="{ root: 'h-full' }"
>
<template #icon>
<icon-lucide-message-circle class="size-5" />
</template>
</AppPlaceholder>
<!-- Messages -->
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">
<ChatMessage
v-for="(msg, index) in messages"
:key="msg.id"
:message="msg"
:streaming="isStreamingMessage(msg, index)"
/>
<!-- Thinking indicator: shown when AI is working but no visible activity -->
<div v-if="isThinking" data-test-id="chat-typing-indicator" class="flex gap-2">
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>
AI
</div>
<div class="flex items-center gap-1 py-2">
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 0ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 150ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 300ms"
/>
</div>
</div>
<!-- Continue button when step limit reached -->
<div v-if="showContinue" class="flex justify-center py-2">
<button
class="flex items-center gap-1.5 rounded-full bg-accent/10 px-4 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/20"
@click="
submission.submit({
modelText: 'Continue where you left off',
displayText: 'Continue where you left off',
images: [],
nodes: []
})
"
>
<icon-lucide-play class="size-3" />
Continue
</button>
</div>
<div ref="messagesEnd" />
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none p-px select-none">
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<!-- Chat toolbar -->
<div
v-if="messages.length > 0"
class="flex shrink-0 items-center gap-1 border-t border-border px-3 py-1"
>
<AppButton v-if="IS_DEV" color="neutral" variant="ghost" size="xs" @click="handleCopyDebug">
<icon-lucide-clipboard-copy v-if="!debugCopied" class="size-3" />
<icon-lucide-check v-else class="size-3 text-green-400" />
{{ debugCopied ? 'Copied' : 'Copy log' }}
</AppButton>
<AppButton
v-if="IS_DEV && hasACPDebugEntries()"
color="neutral"
variant="ghost"
size="xs"
@click="handleCopyACPLog"
>
<icon-lucide-bug v-if="!acpLogCopied" class="size-3" />
<icon-lucide-check v-else class="size-3 text-green-400" />
{{ acpLogCopied ? 'Copied' : 'ACP log' }}
</AppButton>
<AppButton color="error" variant="ghost" size="xs" @click="handleClearChat">
<icon-lucide-trash-2 class="size-3" />
Clear
</AppButton>
</div>
<ChatInput
:status="status"
:disabled="submission.busy.value"
@submit="submission.submit"
@stop="handleStop"
@error="toast.error"
/>
<ACPPermissionDialog />
</template>
</div>
</template>