* feat(chat): attach images by dropping them on the chat Images could be attached with the attach button or by pasting, but a drop on the chat did nothing: the window's document drop handler left images to the canvas, so they were dropped silently. Images dropped anywhere on the chat panel now attach, through the same path as the attach button and paste, with the same limits and errors. While images are dragged over the panel, an outline over the composer says where they go, or why they cannot be attached while a reply runs or four images are already attached. SVG images are drawn as PNGs, since vision models take raster images. A drag without images, such as a .fig, is left to the window, which opens documents. The canvas and the chat share AppDropOverlay, a typed feedback component with a light tint over surfaces and a cover over fields. * fix(chat): style tables, menus, and link prompts in AI replies vue-stream-markdown drew tables with semibold, filled rows at 14px, with copy, download, and fullscreen buttons above each; its tooltips doubled with the browser's, and the link confirmation it teleports out of the message had no app colors, since the token mapping sat on the message element only. The token mapping now lives in markdown.css on the renderer's .stream-markdown elements, so teleported dialogs and menus use app colors. Tables use regular weight, a light header, and the chat density's size, with copy as their only action; task items show their checkbox in place of the bullet; the copy menu and link prompt follow the app's menus and dialogs. Hover labels use the app's tooltip, while click menus keep the renderer's. Links no longer request each site's favicon. * fix(chat): keep an SVG drawn after sending out of the next message Drawing a dropped SVG as PNG takes a moment. A message sent or cleared meanwhile no longer gets the image added to the following draft: each draft has a generation that sending and clearing advance, and an image finished for an earlier one is dropped.
276 lines
9.3 KiB
Vue
276 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
import type { Chat } from '@ai-sdk/vue'
|
|
import type { UIMessage } from 'ai'
|
|
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
|
|
|
import { useI18n } from '@open-pencil/vue'
|
|
|
|
import { revokeImagePreviewURL } from '@/app/ai/attachment/image/prepare'
|
|
import { chatDocumentId } from '@/app/ai/chat/history/document'
|
|
import { useChatRunLocation } from '@/app/ai/chat/run-location'
|
|
import type { ChatSubmission } from '@/app/ai/chat/submission/types'
|
|
import { useChatSubmission } from '@/app/ai/chat/submission/use'
|
|
import { useAIChat } from '@/app/ai/chat/use'
|
|
import { openAISetup } from '@/app/ai/models/settings/onboarding/dialog'
|
|
import { didHitStepLimit } from '@/app/ai/tools'
|
|
import { getActiveEditorStore } from '@/app/editor/active-store'
|
|
import { openSettingsDialog } from '@/app/settings/dialog'
|
|
import { toast } from '@/app/shell/ui'
|
|
import { activeTab } from '@/app/tabs'
|
|
import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue'
|
|
import ChatHistory from '@/components/chat/ChatHistory.vue'
|
|
import ChatInput from '@/components/chat/ChatInput.vue'
|
|
import ChatRunLocation from '@/components/chat/ChatRunLocation.vue'
|
|
import ChatTranscript from '@/components/chat/ChatTranscript.vue'
|
|
import FollowAgentsToggle from '@/components/chat/FollowAgentsToggle.vue'
|
|
import { useImageDrop } from '@/components/chat/input/useImageDrop'
|
|
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
|
|
|
|
const { isConfigured, ensureChat, history, chatFailure, clearChatFailure } = useAIChat()
|
|
const { ai } = useI18n()
|
|
const runLocation = useChatRunLocation()
|
|
|
|
const chat = shallowRef<Chat<UIMessage> | null>(null)
|
|
const submission = useChatSubmission({
|
|
chat,
|
|
ensureChat,
|
|
flush: history.flush,
|
|
clearFailure: clearChatFailure,
|
|
getEditor: getActiveEditorStore,
|
|
messages: computed(() => ({
|
|
openSettings: ai.value.openProviderSettingsAction,
|
|
requestFailed: ai.value.chatRequestFailed,
|
|
visionUnavailable: ai.value.visionModelUnavailable,
|
|
runSetup: ai.value.aiSetupRun,
|
|
agentSetup: {
|
|
'companion-missing': ai.value.chatPiCompanionMissing,
|
|
'companion-outdated': ai.value.chatPiCompanionOutdated,
|
|
'mcp-outdated': ai.value.chatMCPOutdated,
|
|
'pi-sign-in': ai.value.chatPiSignIn,
|
|
'pi-model': ai.value.chatPiModel
|
|
}
|
|
})),
|
|
reportError: toast.error,
|
|
openModelSettings: () => openSettingsDialog('ai'),
|
|
openSetup: () => openAISetup()
|
|
})
|
|
|
|
const chatInput = useTemplateRef<{
|
|
restoreDraft: (submission: ChatSubmission) => void
|
|
dropFiles: (files: File[]) => void
|
|
}>('chatInput')
|
|
const panel = useTemplateRef<HTMLElement>('panel')
|
|
// Images dropped anywhere on the chat go to the composer, when there is one to take them.
|
|
const { dragging: draggingImages } = useImageDrop(panel, {
|
|
enabled: () => chatInput.value !== null,
|
|
onImages: (files) => chatInput.value?.dropFiles(files)
|
|
})
|
|
let viewGeneration = 0
|
|
|
|
/** Puts an unsent message back only in the conversation it was written in. */
|
|
async function submitMessage(message: ChatSubmission) {
|
|
const generation = viewGeneration
|
|
if (await submission.submit(message)) return
|
|
if (generation === viewGeneration) chatInput.value?.restoreDraft(message)
|
|
else for (const image of message.images) revokeImagePreviewURL(image.previewURL)
|
|
}
|
|
// Restoring local history must not open a provider connection or read credentials.
|
|
void history.initialize().catch(() => {
|
|
toast.error(ai.value.chatHistoryFailed)
|
|
})
|
|
|
|
const messages = computed(() => chat.value?.messages ?? history.messages.value)
|
|
const historyOptions = computed(() => {
|
|
const current = history.current.value
|
|
const rows = [...history.conversations.value]
|
|
if (current && !rows.some((row) => row.id === current.id)) rows.unshift(current)
|
|
return rows.map((conversation) => ({
|
|
...conversation,
|
|
available: conversation.documentId === chatDocumentId(getActiveEditorStore())
|
|
}))
|
|
})
|
|
const agentHistoryReadOnly = computed(
|
|
() => !chat.value && messages.value.length > 0 && history.current.value?.backend !== 'direct'
|
|
)
|
|
async function historyAction(action: () => Promise<unknown>) {
|
|
const generation = ++viewGeneration
|
|
submission.cancel()
|
|
try {
|
|
await chat.value?.stop()
|
|
await action()
|
|
if (generation !== viewGeneration) return
|
|
chat.value = null
|
|
} catch {
|
|
toast.error(ai.value.chatHistoryFailed)
|
|
}
|
|
}
|
|
|
|
async function renameConversation(id: string, title: string) {
|
|
try {
|
|
await history.rename(id, title)
|
|
} catch {
|
|
toast.error(ai.value.chatHistoryFailed)
|
|
}
|
|
}
|
|
|
|
const failureMessage = computed(() => {
|
|
switch (chatFailure.value?.reason) {
|
|
case 'authentication':
|
|
return ai.value.chatAuthenticationFailed
|
|
case 'forbidden':
|
|
return ai.value.chatForbidden
|
|
case 'insufficient-credit':
|
|
return ai.value.chatInsufficientCredit
|
|
case 'model-not-found':
|
|
return ai.value.chatModelNotFound
|
|
case 'network':
|
|
return ai.value.chatNetworkFailed
|
|
case 'output-limit':
|
|
return ai.value.chatOutputLimit
|
|
case 'rate-limit':
|
|
return ai.value.chatRateLimited
|
|
case 'request-failed':
|
|
return ai.value.chatRequestFailed
|
|
default:
|
|
return null
|
|
}
|
|
})
|
|
const failureHasSettingsAction = computed(() =>
|
|
['authentication', 'forbidden', 'model-not-found'].includes(chatFailure.value?.reason ?? '')
|
|
)
|
|
const status = computed(() => chat.value?.status ?? 'ready')
|
|
const showContinue = computed(() => {
|
|
if (history.readOnly.value || agentHistoryReadOnly.value) return false
|
|
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()
|
|
})
|
|
|
|
watch(
|
|
() => chatFailure.value?.reason,
|
|
(reason) => {
|
|
if (!reason) return
|
|
toast.error(
|
|
failureMessage.value ?? ai.value.chatRequestFailed,
|
|
failureHasSettingsAction.value
|
|
? {
|
|
label: ai.value.openProviderSettingsAction,
|
|
run: () => openSettingsDialog('ai')
|
|
}
|
|
: undefined
|
|
)
|
|
}
|
|
)
|
|
watch(
|
|
() => [activeTab.value?.id, activeTab.value?.store.state.preparation] as const,
|
|
async ([tabId, preparation], [previousTabId]) => {
|
|
if (preparation) {
|
|
viewGeneration++
|
|
submission.cancel()
|
|
return
|
|
}
|
|
const generation = ++viewGeneration
|
|
const conversationId = history.current.value?.id
|
|
submission.cancel()
|
|
if (tabId !== previousTabId) chat.value = null
|
|
try {
|
|
await history.initialize()
|
|
} catch {
|
|
if (generation === viewGeneration) {
|
|
chat.value = null
|
|
toast.error(ai.value.chatHistoryFailed)
|
|
}
|
|
return
|
|
}
|
|
// A page switch keeps the document and its conversation, so a running chat stays attached.
|
|
if (generation === viewGeneration && history.current.value?.id !== conversationId) {
|
|
chat.value = null
|
|
}
|
|
}
|
|
)
|
|
|
|
function handleStop() {
|
|
submission.stop()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="panel"
|
|
data-test-id="chat-panel"
|
|
class="flex min-w-0 flex-1 flex-col overflow-hidden select-text"
|
|
>
|
|
<ChatHistory
|
|
:saved="history.conversations.value.some((row) => row.id === history.current.value?.id)"
|
|
:conversations="historyOptions"
|
|
:selected-id="history.current.value?.id"
|
|
:disabled="history.busy.value"
|
|
@create="historyAction(history.newChat)"
|
|
@select="historyAction(() => history.open($event))"
|
|
@rename="renameConversation"
|
|
@delete="historyAction(() => history.remove($event))"
|
|
>
|
|
<template #actions><FollowAgentsToggle /></template>
|
|
</ChatHistory>
|
|
<p v-if="history.storageError.value" role="alert" class="px-3 py-2 text-xs text-red-400">
|
|
{{ ai.chatStorageFailed }}
|
|
</p>
|
|
<ProviderSetup v-if="!isConfigured" />
|
|
|
|
<template v-if="isConfigured || messages.length">
|
|
<p
|
|
v-if="history.current.value?.interrupted && status === 'ready'"
|
|
role="status"
|
|
class="px-3 py-2 text-xs text-muted"
|
|
>
|
|
{{ ai.chatInterrupted }}
|
|
</p>
|
|
<ChatTranscript
|
|
:messages="messages"
|
|
:status="status"
|
|
:show-continue="showContinue"
|
|
:nodes-live="!history.readOnly.value"
|
|
:interactive="chat !== null"
|
|
@regenerate="submission.regenerate()"
|
|
@revert="(messageId) => submission.revert(messageId)"
|
|
@restore="(messageId) => submission.restore(messageId)"
|
|
@edit="(messageId, text) => submission.resend(messageId, text)"
|
|
@continue="
|
|
submission.submit({
|
|
modelText: 'Continue where you left off',
|
|
displayText: 'Continue where you left off',
|
|
images: [],
|
|
nodes: []
|
|
})
|
|
"
|
|
/>
|
|
|
|
<p v-if="agentHistoryReadOnly" role="status" class="px-3 py-2 text-xs text-muted">
|
|
{{ ai.chatAgentReadOnly }}
|
|
</p>
|
|
<p v-if="history.readOnly.value" role="status" class="px-3 py-2 text-xs text-muted">
|
|
{{ ai.chatReadOnly }}
|
|
</p>
|
|
<ChatRunLocation
|
|
v-if="runLocation"
|
|
:agent="runLocation.agent"
|
|
:page="runLocation.page"
|
|
@open="runLocation.open"
|
|
/>
|
|
<ChatInput
|
|
v-if="isConfigured && !agentHistoryReadOnly && !history.readOnly.value"
|
|
ref="chatInput"
|
|
:status="status"
|
|
:dragging="draggingImages"
|
|
:disabled="submission.busy.value || history.busy.value"
|
|
@submit="submitMessage"
|
|
@stop="handleStop"
|
|
@error="toast.error"
|
|
/>
|
|
|
|
<ACPPermissionDialog />
|
|
</template>
|
|
</div>
|
|
</template>
|