openpencil/src/components/chat/ChatInput.vue
Danila Poyarkov b4e479d9d0
feat: persist AI conversations and add chat history (#661)
* feat: persist AI conversations and add chat history

Store transcripts and attachment previews in IndexedDB, separate conversation history from transport lifetime, and add document-aware switching with shared Storybook coverage. Preserve interrupted activity and guard stale writes and async switches.

* test: group chat history tests by domain

* feat: simplify chat history navigation and diagnostics

Use a compact header with searchable document-scoped history and a correctly anchored conversation menu. Move diagnostic copying into the menu with copy-result feedback, and separate Storybook fixtures from composition.

* fix: address chat history review findings

* test: cover harness shutdown and restored chat scrolling

* fix: preserve Portless proxy port in MCP routes

* docs: clarify UI animation conventions

* feat: configure reasoning display and animate disclosure

* fix: separate transcript following from reasoning disclosure

* fix: restore selected chat after document recovery

* refactor: separate chat history persistence and sessions

* style: format reasoning story imports
2026-09-08 11:19:07 +03:00

172 lines
5.8 KiB
Vue

<script setup lang="ts">
import { computed } 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/button/IconButton.vue'
import ChatComposer from './ChatComposer.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 { designModelProfile } from '@/app/ai/models'
import { openSettingsDialog } from '@/app/settings/dialog'
import { ACP_AGENTS } from '@open-pencil/core/constants'
const { providerID, providerDef, modelID, customModelID } = useAIChat()
const { editor, selectedIds } = useSelectionState()
const { ai } = useI18n()
const { status, disabled = false } = defineProps<{
status: 'ready' | 'submitted' | 'streaming' | 'error'
disabled?: boolean
}>()
const emit = defineEmits<{
submit: [submission: ChatSubmission]
stop: []
error: [message: string]
}>()
const attachments = useAttachmentDrafts({
editor,
selectedIds,
reportError: (message) => emit('error', message)
})
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(
() => providerID.value.startsWith('acp:') || providerID.value === 'harness:pi'
)
const agentName = computed(() => {
if (providerID.value === 'harness:pi') return 'Pi'
const agentId = providerID.value.replace('acp:', '')
return ACP_AGENTS.find((a) => a.id === agentId)?.name ?? agentId
})
const isCustomProvider = computed(
() => providerID.value === 'openai-compatible' || providerID.value === 'anthropic-compatible'
)
const customModelName = computed(() => customModelID.value.trim())
const usesCustomModel = computed(
() => !!providerDef.value.supportsCustomModel && !!customModelName.value
)
const selectedModelName = computed(() => {
if (usesCustomModel.value) return customModelName.value
if (isCustomProvider.value) return 'No model'
return providerDef.value.models.find((m) => m.id === modelID.value)?.name ?? modelID.value
})
// The composer switches configured Design-role profiles; raw provider model selection lives in Settings.
const selectedProfileName = computed(
() => designModelProfile.value?.name ?? selectedModelName.value
)
</script>
<template>
<ChatComposer
:status="status"
:disabled="disabled"
@submit="emit('submit', takeSubmission($event))"
@stop="emit('stop')"
@paste="handlePaste"
@settings="openSettingsDialog('ai')"
>
<template v-if="images.length || referencedNodes.length" #attachment>
<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 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
>
<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-3" />
</IconButton>
</div>
<div
v-for="(image, index) in images"
:key="image.previewURL"
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
>
<img
:src="image.previewURL"
:alt="image.file.name"
width="40"
height="40"
class="size-10 shrink-0 rounded-md border border-border object-cover"
/>
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ image.file.name }}
</span>
<IconButton
:label="ai.removeImageAttachment({ name: image.file.name })"
size="xs"
@click="removeImage(index)"
>
<icon-lucide-x class="size-3" />
</IconButton>
</div>
</div>
</template>
<template #leading>
<IconButton
:label="ai.addSelectionContext"
size="sm"
:active="selectionContextActive"
:disabled="isStreaming || !canAddSelection"
data-slot="chat-add-selection-context"
@click="toggleCurrentSelection"
>
<icon-lucide-mouse-pointer-2 class="size-4" />
</IconButton>
<IconButton
:label="ai.attachImages"
size="sm"
:disabled="isStreaming || images.length >= MAX_IMAGE_ATTACHMENTS"
@click="openImageDialog()"
>
<icon-lucide-image-plus class="size-4" />
</IconButton>
</template>
<template #model>
<div class="flex min-w-0 items-center">
<template v-if="isAgentProvider">
<div class="flex min-w-0 items-center gap-1 px-1.5 text-[10px] text-muted">
<icon-lucide-bot class="size-3 shrink-0" />
<span class="truncate">{{ agentName }}</span>
</div>
</template>
<ChatProfileSelect v-else>
<template #value>
<span class="min-w-0 truncate">{{ selectedProfileName }}</span>
</template>
</ChatProfileSelect>
</div>
</template>
</ChatComposer>
</template>