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
This commit is contained in:
Danila Poyarkov 2026-09-04 11:16:32 +03:00
parent bd24220b9a
commit db68b71ae1
5 changed files with 355 additions and 268 deletions

View file

@ -0,0 +1,9 @@
import type { ImageAttachmentDraft } from '@/app/ai/attachment/image/types'
import type { ReferencedNode } from '@/app/ai/chat/context'
export interface ChatSubmission {
modelText: string
displayText: string
images: ImageAttachmentDraft[]
nodes: ReferencedNode[]
}

View file

@ -0,0 +1,151 @@
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
import { computed, markRaw, ref, type Ref } from 'vue'
import {
analyzeAttachedImages,
designMessageWithImageFindings,
VisionModelUnavailableError
} from '@/app/ai/attachment/image/analyze'
import { prepareImageAttachment, revokeImagePreviewURL } from '@/app/ai/attachment/image/prepare'
import {
imageDraftPresentations,
preparedImagePresentations
} from '@/app/ai/attachment/image/presentation'
import { snapshotNode } from '@/app/ai/attachment/node/snapshot'
import { setMessageAttachments } from '@/app/ai/attachment/presentation/store'
import { setVisibleMessageText } from '@/app/ai/chat/presentation'
import type { ChatSubmission } from '@/app/ai/chat/submission/types'
import type { EditorStore } from '@/app/editor/active-store'
export type ChatInstance = Pick<Chat<UIMessage>, 'messages' | 'sendMessage' | 'stop'>
interface SubmissionMessages {
requestFailed: string
visionUnavailable: string
}
interface SubmissionOptions {
chat: Ref<Chat<UIMessage> | null>
ensureChat: () => Promise<Chat<UIMessage> | null>
clearFailure: () => void
getEditor: () => EditorStore
messages: Ref<SubmissionMessages>
reportError: (message: string) => void
openModelSettings: () => void
}
export function useChatSubmission(options: SubmissionOptions) {
const isPreparingAttachments = ref(false)
let operationVersion = 0
async function sendText(currentChat: ChatInstance, submission: ChatSubmission): Promise<void> {
const previousIds = new Set(currentChat.messages.map((message) => message.id))
await currentChat.sendMessage({ text: submission.modelText })
const message = currentChat.messages.find(
(candidate) => candidate.role === 'user' && !previousIds.has(candidate.id)
)
if (message) setVisibleMessageText(message.id, submission.displayText)
}
async function sendAttachments(
currentChat: ChatInstance,
submission: ChatSubmission,
version: number
): Promise<void> {
const messageId = crypto.randomUUID()
const editor = options.getEditor()
const nodeAttachments = submission.nodes
.map((node) => snapshotNode(editor, messageId, node))
.filter((attachment) => attachment !== null)
currentChat.messages = [
...currentChat.messages,
{ id: messageId, role: 'user', parts: [{ type: 'text', text: submission.modelText }] }
]
setVisibleMessageText(messageId, submission.displayText)
const draftImages = imageDraftPresentations(messageId, submission.images)
setMessageAttachments(messageId, [...nodeAttachments, ...draftImages])
for (const image of submission.images) revokeImagePreviewURL(image.previewURL)
if (submission.images.length === 0) {
await currentChat.sendMessage({ messageId, text: submission.modelText })
return
}
const preparedImages = await Promise.all(
submission.images.map((image) => prepareImageAttachment(image.file))
)
const findings = await analyzeAttachedImages(editor, submission.modelText, preparedImages)
if (version !== operationVersion || options.chat.value !== currentChat) return
const normalizedImages = preparedImagePresentations(
messageId,
submission.images,
preparedImages
)
setMessageAttachments(messageId, [...nodeAttachments, ...normalizedImages])
await currentChat.sendMessage({
messageId,
text: designMessageWithImageFindings(
submission.modelText,
submission.images.map((image) => image.file.name),
findings
)
})
}
function reportSubmissionError(error: unknown): void {
console.error('Chat error:', error)
if (error instanceof VisionModelUnavailableError) {
options.reportError(options.messages.value.visionUnavailable)
options.openModelSettings()
return
}
options.reportError(options.messages.value.requestFailed)
}
async function submit(submission: ChatSubmission): Promise<void> {
const status = options.chat.value?.status ?? 'ready'
if (status === 'streaming' || status === 'submitted' || isPreparingAttachments.value) {
for (const image of submission.images) revokeImagePreviewURL(image.previewURL)
if (submission.images.length > 0) options.reportError(options.messages.value.requestFailed)
return
}
const version = ++operationVersion
isPreparingAttachments.value = submission.images.length > 0
options.clearFailure()
try {
const currentChat = options.chat.value ?? (await options.ensureChat())
if (currentChat) options.chat.value = markRaw(currentChat)
if (!currentChat || version !== operationVersion) {
for (const image of submission.images) revokeImagePreviewURL(image.previewURL)
if (submission.images.length > 0) options.reportError(options.messages.value.requestFailed)
return
}
if (submission.images.length === 0 && submission.nodes.length === 0) {
await sendText(currentChat, submission)
} else {
await sendAttachments(currentChat, submission, version)
}
} catch (error) {
reportSubmissionError(error)
} finally {
if (version === operationVersion) isPreparingAttachments.value = false
}
}
function cancel(): void {
operationVersion += 1
isPreparingAttachments.value = false
}
const busy = computed(() => isPreparingAttachments.value)
return {
busy,
cancel,
stop: () => options.chat.value?.stop(),
submit
}
}

View file

@ -1,28 +1,13 @@
<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { refAutoReset, useClipboard } from '@vueuse/core'
import { computed, markRaw, nextTick, ref, watch } from 'vue'
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, setVisibleMessageText } from '@/app/ai/chat/presentation'
import {
analyzeAttachedImages,
designMessageWithImageFindings,
VisionModelUnavailableError
} from '@/app/ai/attachment/image/analyze'
import {
imageDraftPresentations,
preparedImagePresentations
} from '@/app/ai/attachment/image/presentation'
import { prepareImageAttachment, revokeImagePreviewURL } from '@/app/ai/attachment/image/prepare'
import {
clearMessageAttachments,
setMessageAttachments
} from '@/app/ai/attachment/presentation/store'
import { snapshotNode } from '@/app/ai/attachment/node/snapshot'
import type { ReferencedNode } from '@/app/ai/chat/context'
import type { ImageAttachmentDraft } from '@/app/ai/attachment/image/types'
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'
@ -50,9 +35,19 @@ const { copy } = useClipboard()
const { ai } = useI18n()
const notifications = useNotificationMessages()
const chat = ref<Chat<UIMessage> | null>(null)
const isPreparingImages = ref(false)
let attachmentOperationVersion = 0
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) => {
@ -130,8 +125,7 @@ watch(
watch(
() => activeTab.value?.id,
async () => {
attachmentOperationVersion += 1
isPreparingImages.value = false
submission.cancel()
clearMessageAttachments()
clearVisibleMessageText()
const nextChat = await ensureChat()
@ -139,123 +133,8 @@ watch(
}
)
type ChatInstance = Pick<Chat<UIMessage>, 'messages' | 'sendMessage'>
async function sendTextMessage(
currentChat: ChatInstance,
text: string,
displayText: string
): Promise<void> {
const previousMessageIds = new Set(currentChat.messages.map((message) => message.id))
await currentChat.sendMessage({ text })
const message = currentChat.messages.find(
(candidate) => candidate.role === 'user' && !previousMessageIds.has(candidate.id)
)
if (message) setVisibleMessageText(message.id, displayText)
}
function reportSubmitError(error: unknown): void {
console.error('Chat error:', error)
if (error instanceof VisionModelUnavailableError) {
toast.error(ai.value.visionModelUnavailable)
openSettingsDialog('ai')
return
}
toast.error(ai.value.chatRequestFailed)
}
async function sendAttachmentMessage(
currentChat: ChatInstance,
messageId: string,
text: string,
displayText: string,
images: ImageAttachmentDraft[],
nodes: ReferencedNode[],
operationVersion: number
): Promise<void> {
const editor = getActiveEditorStore()
const nodeAttachments = nodes
.map((node) => snapshotNode(editor, messageId, node))
.filter((attachment) => attachment !== null)
currentChat.messages = [
...currentChat.messages,
{ id: messageId, role: 'user', parts: [{ type: 'text', text }] }
]
setVisibleMessageText(messageId, displayText)
const draftImageAttachments = imageDraftPresentations(messageId, images)
setMessageAttachments(messageId, [...nodeAttachments, ...draftImageAttachments])
for (const image of images) revokeImagePreviewURL(image.previewURL)
if (images.length === 0) {
await currentChat.sendMessage({ messageId, text })
return
}
const preparedImages = await Promise.all(
images.map((image) => prepareImageAttachment(image.file))
)
const findings = await analyzeAttachedImages(editor, text, preparedImages)
if (operationVersion !== attachmentOperationVersion || chat.value !== currentChat) return
const preparedImageAttachments = preparedImagePresentations(messageId, images, preparedImages)
setMessageAttachments(messageId, [...nodeAttachments, ...preparedImageAttachments])
await currentChat.sendMessage({
messageId,
text: designMessageWithImageFindings(
text,
images.map((image) => image.file.name),
findings
)
})
}
async function handleSubmit(
text: string,
images: ImageAttachmentDraft[] = [],
displayText = text,
nodes: ReferencedNode[] = []
) {
if (status.value === 'streaming' || status.value === 'submitted' || isPreparingImages.value) {
for (const image of images) revokeImagePreviewURL(image.previewURL)
if (images.length > 0) toast.error(ai.value.chatRequestFailed)
return
}
const operationVersion = ++attachmentOperationVersion
if (images.length > 0) isPreparingImages.value = true
clearChatFailure()
try {
const currentChat = chat.value ?? (await ensureChat())
if (currentChat) chat.value = markRaw(currentChat)
if (!currentChat || operationVersion !== attachmentOperationVersion) {
for (const image of images) revokeImagePreviewURL(image.previewURL)
if (images.length > 0) toast.error(ai.value.chatRequestFailed)
return
}
if (images.length === 0 && nodes.length === 0) {
await sendTextMessage(currentChat, text, displayText)
return
}
await sendAttachmentMessage(
currentChat,
crypto.randomUUID(),
text,
displayText,
images,
nodes,
operationVersion
)
} catch (error) {
reportSubmitError(error)
} finally {
if (operationVersion === attachmentOperationVersion) isPreparingImages.value = false
}
}
function handleStop() {
chat.value?.stop()
submission.stop()
}
async function handleCopyDebug() {
@ -271,8 +150,7 @@ async function handleCopyACPLog() {
}
function handleClearChat() {
attachmentOperationVersion += 1
isPreparingImages.value = false
submission.cancel()
clearChatFailure()
clearMessageAttachments()
clearVisibleMessageText()
@ -339,7 +217,14 @@ function handleClearChat() {
<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="handleSubmit('Continue where you left off')"
@click="
submission.submit({
modelText: 'Continue where you left off',
displayText: 'Continue where you left off',
images: [],
nodes: []
})
"
>
<icon-lucide-play class="size-3" />
Continue
@ -383,8 +268,8 @@ function handleClearChat() {
<ChatInput
:status="status"
:disabled="isPreparingImages"
@submit="handleSubmit"
:disabled="submission.busy.value"
@submit="submission.submit"
@stop="handleStop"
@error="toast.error"
/>

View file

@ -1,28 +1,19 @@
<script setup lang="ts">
import { useFileDialog, useTextareaAutosize } from '@vueuse/core'
import { useTextareaAutosize } from '@vueuse/core'
import { TooltipProvider } from 'reka-ui'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n, useSelectionState } from '@open-pencil/vue'
import ChatNodePreview from '@/components/chat/ChatNodePreview.vue'
import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
import { useAttachmentDrafts } from '@/components/chat/input/useAttachments'
import IconButton from '@/components/ui/IconButton.vue'
import InputGroup from '@/components/ui/InputGroup.vue'
import { MAX_IMAGE_ATTACHMENTS } from '@/app/ai/attachment/image/types'
import type { ChatSubmission } from '@/app/ai/chat/submission/types'
import { useAIChat } from '@/app/ai/chat/use'
import {
appendReferencedNodeContext,
MAX_REFERENCED_NODES,
resolveReferencedNodes,
type ReferencedNode
} from '@/app/ai/chat/context'
import { designModelProfile } from '@/app/ai/models'
import {
createImagePreviewURL,
revokeImagePreviewURL,
validateImageAttachmentFile
} from '@/app/ai/attachment/image/prepare'
import { MAX_IMAGE_ATTACHMENTS, type ImageAttachmentDraft } from '@/app/ai/attachment/image/types'
import { openSettingsDialog } from '@/app/settings/dialog'
import { ACP_AGENTS } from '@open-pencil/core/constants'
@ -37,12 +28,7 @@ const { status, disabled = false } = defineProps<{
}>()
const emit = defineEmits<{
submit: [
text: string,
images: ImageAttachmentDraft[],
displayText: string,
nodes: ReferencedNode[]
]
submit: [submission: ChatSubmission]
stop: []
error: [message: string]
}>()
@ -50,62 +36,23 @@ const emit = defineEmits<{
const textarea = ref<HTMLTextAreaElement>()
const input = ref('')
const { triggerResize } = useTextareaAutosize({ element: textarea, input, maxHeight: 160 })
const images = ref<ImageAttachmentDraft[]>([])
const referencedNodeIds = ref<string[]>([])
const referencedNodes = computed(() =>
resolveReferencedNodes(editor.graph, referencedNodeIds.value)
)
const selectedReferencedNodeIds = computed(() =>
[...selectedIds.value].filter((id) => referencedNodeIds.value.includes(id))
)
const canAddSelection = computed(
() =>
selectedIds.value.size > 0 &&
(selectedReferencedNodeIds.value.length > 0 ||
referencedNodes.value.length < MAX_REFERENCED_NODES)
)
const selectionContextActive = computed(
() =>
selectedIds.value.size > 0 && selectedReferencedNodeIds.value.length === selectedIds.value.size
)
const {
open: openImageDialog,
reset: resetImageDialog,
onChange: onImageChange
} = useFileDialog({
accept: 'image/png,image/jpeg,image/webp',
multiple: true,
reset: true
const attachments = useAttachmentDrafts({
editor,
selectedIds,
reportError: (message) => emit('error', message)
})
function addImageFiles(files: File[]) {
const available = MAX_IMAGE_ATTACHMENTS - images.value.length
if (available <= 0) {
emit('error', `You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`)
resetImageDialog()
return
}
for (const file of files.slice(0, available)) {
const validationError = validateImageAttachmentFile(file)
if (validationError) {
emit('error', validationError)
continue
}
images.value.push({ file, previewURL: createImagePreviewURL(file) })
}
if (files.length > available) {
emit('error', `You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`)
}
resetImageDialog()
}
function removeImage(index: number) {
const image = images.value[index]
if (image) revokeImagePreviewURL(image.previewURL)
images.value.splice(index, 1)
resetImageDialog()
}
const {
images,
nodes: referencedNodes,
canToggleSelection: canAddSelection,
selectionActive: selectionContextActive,
openImageDialog,
removeImage,
removeNode: removeReferencedNode,
toggleSelection: toggleCurrentSelection,
handlePaste,
takeSubmission
} = attachments
const isStreaming = computed(() => disabled || status === 'streaming' || status === 'submitted')
const isAgentProvider = computed(
@ -135,26 +82,6 @@ const selectedProfileName = computed(
() => designModelProfile.value?.name ?? selectedModelName.value
)
function clearImages() {
for (const image of images.value) revokeImagePreviewURL(image.previewURL)
images.value = []
resetImageDialog()
}
onImageChange((selectedFiles) => {
if (selectedFiles) addImageFiles([...selectedFiles])
})
function handlePaste(event: ClipboardEvent) {
const files = event.clipboardData?.files
const images = files ? [...files].filter((file) => file.type.startsWith('image/')) : []
if (images.length === 0) return
event.preventDefault()
addImageFiles(images)
}
onBeforeUnmount(clearImages)
function handleInputKeydown(event: KeyboardEvent) {
if (event.code !== 'Enter' || event.shiftKey || event.isComposing) return
event.preventDefault()
@ -162,38 +89,11 @@ function handleInputKeydown(event: KeyboardEvent) {
if (target instanceof HTMLElement) target.closest('form')?.requestSubmit()
}
function removeReferencedNode(id: string): void {
referencedNodeIds.value = referencedNodeIds.value.filter((candidate) => candidate !== id)
}
function toggleCurrentSelection(): void {
if (selectedReferencedNodeIds.value.length > 0) {
const selected = new Set(selectedIds.value)
referencedNodeIds.value = referencedNodeIds.value.filter((id) => !selected.has(id))
return
}
referencedNodeIds.value = resolveReferencedNodes(editor.graph, [
...referencedNodeIds.value,
...selectedIds.value
]).map((node) => node.id)
}
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.value.trim()
if (!text) return
const submittedImages = images.value
const submittedNodes = referencedNodes.value
images.value = []
referencedNodeIds.value = []
resetImageDialog()
emit(
'submit',
appendReferencedNodeContext(text, submittedNodes),
submittedImages,
text,
submittedNodes
)
emit('submit', takeSubmission(text))
input.value = ''
triggerResize()
}

View file

@ -0,0 +1,142 @@
import { useFileDialog } from '@vueuse/core'
import { computed, onBeforeUnmount, ref, type Ref } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import {
createImagePreviewURL,
revokeImagePreviewURL,
validateImageAttachmentFile
} from '@/app/ai/attachment/image/prepare'
import { MAX_IMAGE_ATTACHMENTS, type ImageAttachmentDraft } from '@/app/ai/attachment/image/types'
import {
appendReferencedNodeContext,
MAX_REFERENCED_NODES,
resolveReferencedNodes
} from '@/app/ai/chat/context'
import type { ChatSubmission } from '@/app/ai/chat/submission/types'
interface AttachmentDraftOptions {
editor: Editor
selectedIds: Ref<Set<string>>
reportError: (message: string) => void
}
export function useAttachmentDrafts(options: AttachmentDraftOptions) {
const images = ref<ImageAttachmentDraft[]>([])
const nodeIds = ref<string[]>([])
const nodes = computed(() => resolveReferencedNodes(options.editor.graph, nodeIds.value))
const selectedNodeIds = computed(() =>
[...options.selectedIds.value].filter((id) => nodeIds.value.includes(id))
)
const canToggleSelection = computed(
() =>
options.selectedIds.value.size > 0 &&
(selectedNodeIds.value.length > 0 || nodes.value.length < MAX_REFERENCED_NODES)
)
const selectionActive = computed(
() =>
options.selectedIds.value.size > 0 &&
selectedNodeIds.value.length === options.selectedIds.value.size
)
const {
open: openImageDialog,
reset: resetImageDialog,
onChange: onImageChange
} = useFileDialog({
accept: 'image/png,image/jpeg,image/webp',
multiple: true,
reset: true
})
function addImages(files: File[]): void {
const available = MAX_IMAGE_ATTACHMENTS - images.value.length
if (available <= 0) {
options.reportError(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`)
resetImageDialog()
return
}
for (const file of files.slice(0, available)) {
const validationError = validateImageAttachmentFile(file)
if (validationError) {
options.reportError(validationError)
continue
}
images.value.push({ file, previewURL: createImagePreviewURL(file) })
}
if (files.length > available) {
options.reportError(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`)
}
resetImageDialog()
}
function removeImage(index: number): void {
revokeImagePreviewURL(images.value[index].previewURL)
images.value.splice(index, 1)
resetImageDialog()
}
function removeNode(id: string): void {
nodeIds.value = nodeIds.value.filter((candidate) => candidate !== id)
}
function toggleSelection(): void {
if (selectedNodeIds.value.length > 0) {
const selected = new Set(options.selectedIds.value)
nodeIds.value = nodeIds.value.filter((id) => !selected.has(id))
return
}
nodeIds.value = resolveReferencedNodes(options.editor.graph, [
...nodeIds.value,
...options.selectedIds.value
]).map((node) => node.id)
}
function handlePaste(event: ClipboardEvent): void {
const files = event.clipboardData?.files
const pastedImages = files ? [...files].filter((file) => file.type.startsWith('image/')) : []
if (pastedImages.length === 0) return
event.preventDefault()
addImages(pastedImages)
}
function takeSubmission(text: string): ChatSubmission {
const submittedImages = images.value
const submittedNodes = nodes.value
images.value = []
nodeIds.value = []
resetImageDialog()
return {
modelText: appendReferencedNodeContext(text, submittedNodes),
displayText: text,
images: submittedImages,
nodes: submittedNodes
}
}
function clear(): void {
for (const image of images.value) revokeImagePreviewURL(image.previewURL)
images.value = []
nodeIds.value = []
resetImageDialog()
}
onImageChange((files) => {
if (files) addImages([...files])
})
onBeforeUnmount(clear)
return {
images,
nodes,
canToggleSelection,
selectionActive,
openImageDialog,
addImages,
removeImage,
removeNode,
toggleSelection,
handlePaste,
takeSubmission
}
}