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:
parent
bd24220b9a
commit
db68b71ae1
9
src/app/ai/chat/submission/types.ts
Normal file
9
src/app/ai/chat/submission/types.ts
Normal 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[]
|
||||
}
|
||||
151
src/app/ai/chat/submission/use.ts
Normal file
151
src/app/ai/chat/submission/use.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
142
src/components/chat/input/useAttachments.ts
Normal file
142
src/components/chat/input/useAttachments.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue