feat(chat): unify composer attachments
- Render node references and images with one attachment-card layout\n- Generate live previews for referenced scene nodes\n- Make the current-selection control toggle its active selection\n\nCo-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
This commit is contained in:
parent
743078255f
commit
a8c7fedadb
|
|
@ -5,6 +5,7 @@ import { computed, onBeforeUnmount, 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 ProviderModelSelect from '@/components/chat/ProviderModelSelect.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
|
|
@ -13,8 +14,7 @@ import { useAIChat } from '@/app/ai/chat/use'
|
|||
import {
|
||||
appendReferencedNodeContext,
|
||||
MAX_REFERENCED_NODES,
|
||||
resolveReferencedNodes,
|
||||
type ReferencedNode
|
||||
resolveReferencedNodes
|
||||
} from '@/app/ai/chat/context'
|
||||
import { designModelProfile, designModelProfiles } from '@/app/ai/models'
|
||||
import {
|
||||
|
|
@ -23,7 +23,6 @@ import {
|
|||
validateImageAttachmentFile
|
||||
} from '@/app/ai/attachment/image/prepare'
|
||||
import { MAX_IMAGE_ATTACHMENTS, type ImageAttachmentDraft } from '@/app/ai/attachment/image/types'
|
||||
import { nodeIcon } from '@/app/editor/icons'
|
||||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
|
||||
import { ACP_AGENTS } from '@open-pencil/core/constants'
|
||||
|
|
@ -51,11 +50,18 @@ 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 &&
|
||||
referencedNodes.value.length < MAX_REFERENCED_NODES &&
|
||||
[...selectedIds.value].some((id) => !referencedNodeIds.value.includes(id))
|
||||
(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,
|
||||
|
|
@ -157,18 +163,18 @@ function removeReferencedNode(id: string): void {
|
|||
referencedNodeIds.value = referencedNodeIds.value.filter((candidate) => candidate !== id)
|
||||
}
|
||||
|
||||
function addCurrentSelection(): void {
|
||||
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 referencedNodeIcon(node: ReferencedNode) {
|
||||
const liveNode = editor.graph.getNode(node.id)
|
||||
return liveNode ? nodeIcon(liveNode) : undefined
|
||||
}
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const text = input.value.trim()
|
||||
|
|
@ -190,29 +196,25 @@ function handleSubmit(e: Event) {
|
|||
<form @submit="handleSubmit" @paste.stop="handlePaste">
|
||||
<InputGroup :disabled="isStreaming">
|
||||
<template v-if="images.length || referencedNodes.length" #attachment>
|
||||
<div v-if="referencedNodes.length" class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<div
|
||||
v-for="node in referencedNodes"
|
||||
:key="node.id"
|
||||
data-slot="chat-context-chip"
|
||||
class="flex min-w-0 items-center gap-1 rounded-md border border-border bg-hover px-1.5 py-0.5 text-[10px] text-surface"
|
||||
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
|
||||
>
|
||||
<component
|
||||
:is="referencedNodeIcon(node)"
|
||||
class="size-2.5 shrink-0 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="max-w-24 truncate">{{ node.name || node.type }}</span>
|
||||
<ChatNodePreview :editor="editor" :node="node" />
|
||||
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
|
||||
{{ node.name || node.type }}
|
||||
</span>
|
||||
<IconButton
|
||||
:label="ai.removeNodeContext"
|
||||
size="xs"
|
||||
@click="removeReferencedNode(node.id)"
|
||||
>
|
||||
<icon-lucide-x class="size-2.5" />
|
||||
<icon-lucide-x class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="images.length" class="flex flex-wrap gap-1.5">
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="image.previewURL"
|
||||
|
|
@ -257,9 +259,10 @@ function handleSubmit(e: Event) {
|
|||
<IconButton
|
||||
:label="ai.addSelectionContext"
|
||||
size="sm"
|
||||
:active="selectionContextActive"
|
||||
:disabled="isStreaming || !canAddSelection"
|
||||
data-slot="chat-add-selection-context"
|
||||
@click="addCurrentSelection"
|
||||
@click="toggleCurrentSelection"
|
||||
>
|
||||
<icon-lucide-mouse-pointer-2 class="size-4" />
|
||||
</IconButton>
|
||||
|
|
|
|||
49
src/components/chat/ChatNodePreview.vue
Normal file
49
src/components/chat/ChatNodePreview.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { useObjectUrl } from '@vueuse/core'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { renderNodesToImage } from '@open-pencil/core/io'
|
||||
import type { ReferencedNode } from '@/app/ai/chat/context'
|
||||
import type { Editor } from '@open-pencil/core/editor'
|
||||
|
||||
const { editor, node } = defineProps<{ editor: Editor; node: ReferencedNode }>()
|
||||
const blob = shallowRef<Blob | null>(null)
|
||||
const previewURL = useObjectUrl(blob)
|
||||
let requestId = 0
|
||||
|
||||
watch(
|
||||
() => node.id,
|
||||
async () => {
|
||||
const request = ++requestId
|
||||
const renderer = editor.renderer
|
||||
const liveNode = editor.graph.getNode(node.id)
|
||||
if (!renderer || !liveNode) return
|
||||
const maxDimension = Math.max(liveNode.width, liveNode.height, 1)
|
||||
const data = await renderNodesToImage(
|
||||
renderer.ck,
|
||||
renderer,
|
||||
editor.graph,
|
||||
editor.state.currentPageId,
|
||||
[node.id],
|
||||
{ scale: 40 / maxDimension, format: 'PNG' }
|
||||
)
|
||||
if (request === requestId) blob.value = data ? new Blob([data], { type: 'image/png' }) : null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="previewURL"
|
||||
:src="previewURL"
|
||||
alt=""
|
||||
class="size-10 shrink-0 rounded-md border border-border bg-canvas object-contain"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-canvas"
|
||||
>
|
||||
<icon-lucide-box class="size-4 text-muted" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -238,6 +238,14 @@ test('selected nodes can be pinned without exposing context metadata in the chat
|
|||
})
|
||||
|
||||
await page.getByRole('button', { name: 'Add current selection as context' }).click()
|
||||
const contextButton = page.getByRole('button', { name: 'Add current selection as context' })
|
||||
await expect(contextButton).toHaveAttribute('data-state', 'on')
|
||||
await expect(contextButton).toBeEnabled()
|
||||
await expect(page.locator('[data-slot="chat-context-chip"]')).toContainText('Pinned hero')
|
||||
await expect(page.locator('[data-slot="chat-context-chip"] img')).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(page.locator('[data-slot="chat-context-chip"]')).toHaveCount(0)
|
||||
await contextButton.click()
|
||||
await expect(page.locator('[data-slot="chat-context-chip"]')).toContainText('Pinned hero')
|
||||
await chatInput().fill('Make it larger')
|
||||
await chatInput().press('Enter')
|
||||
|
|
|
|||
Loading…
Reference in a new issue