提示输入
允许用户向大型语言模型发送带有文件附件的消息。它包括一个文本区域、文件上传功能、提交按钮和用于选择模型的下拉菜单。
PromptInput 组件允许用户向大型语言模型发送带有文件附件的消息。它包括一个文本区域、文件上传功能、提交按钮和用于选择模型的下拉菜单。
使用 CLI 安装
AI Elements Vue
shadcn-vue CLI
npx ai-elements-vue@latest add prompt-input
npx shadcn-vue@latest add https://registry.ai-elements-vue.com/prompt-input.json
手动安装
将以下代码复制并粘贴到同一文件夹中。
PromptInputProvider.vue
PromptInput.vue
PromptInputTextarea.vue
PromptInputTools.vue
PromptInputButton.vue
PromptInputSubmit.vue
PromptInputBody.vue
PromptInputAttachments.vue
PromptInputAttachment.vue
PromptInputHeader.vue
PromptInputFooter.vue
PromptInputActionMenu.vue
PromptInputActionMenuTrigger.vue
PromptInputActionMenuContent.vue
PromptInputActionMenuItem.vue
PromptInputActionAddAttachments.vue
PromptInputSpeechButton.vue
PromptInputSelect.vue
PromptInputSelectTrigger.vue
PromptInputSelectContent.vue
PromptInputSelectItem.vue
PromptInputSelectValue.vue
PromptInputHoverCard.vue
PromptInputHoverCardTrigger.vue
PromptInputHoverCardContent.vue
PromptInputTabsList.vue
PromptInputTab.vue
PromptInputTabLabel.vue
PromptInputTabBody.vue
PromptInputTabItem.vue
PromptInputCommand.vue
PromptInputCommandInput.vue
PromptInputCommandList.vue
PromptInputCommandEmpty.vue
PromptInputCommandGroup.vue
PromptInputCommandItem.vue
PromptInputCommandSeparator.vue
context.ts
types.ts
index.ts
<script setup lang="ts">
import type { PromptInputMessage } from './types'
import { usePromptInputProvider } from './context'
const props = defineProps<{
initialInput?: string
maxFiles?: number
maxFileSize?: number
accept?: string
}>()
const emit = defineEmits<{
(e: 'submit', payload: PromptInputMessage): void
(e: 'error', payload: { code: string, message: string }): void
}>()
usePromptInputProvider({
initialInput: props.initialInput,
maxFiles: props.maxFiles,
maxFileSize: props.maxFileSize,
accept: props.accept,
onSubmit: msg => emit('submit', msg),
onError: err => emit('error', err),
})
</script>
<template>
<slot />
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { PromptInputMessage } from './types'
import { InputGroup } from '@repo/shadcn-vue/components/ui/input-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { inject, onMounted, onUnmounted, ref } from 'vue'
import { usePromptInputProvider } from './context'
import { PROMPT_INPUT_KEY } from './types'
const props = defineProps<{
class?: HTMLAttributes['class']
accept?: string
multiple?: boolean
globalDrop?: boolean
maxFiles?: number
maxFileSize?: number
initialInput?: string
}>()
const emit = defineEmits<{
(e: 'submit', payload: PromptInputMessage): void
(e: 'error', payload: { code: string, message: string }): void
}>()
const formRef = ref<HTMLFormElement | null>(null)
// --- Dual-mode context handling ---
const inheritedContext = inject(PROMPT_INPUT_KEY, null)
const localContext = inheritedContext
? null
: usePromptInputProvider({
initialInput: props.initialInput,
maxFiles: props.maxFiles,
maxFileSize: props.maxFileSize,
accept: props.accept,
onSubmit: msg => emit('submit', msg as any),
onError: err => emit('error', err),
})
const context = inheritedContext || localContext
if (!context) {
throw new Error('PromptInput context is missing.')
}
const { fileInputRef, addFiles, submitForm } = context
function handleDragOver(e: DragEvent) {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
}
function handleDrop(e: DragEvent) {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
onMounted(() => {
if (props.globalDrop) {
document.addEventListener('dragover', handleDragOver)
document.addEventListener('drop', handleDrop)
}
})
onUnmounted(() => {
if (props.globalDrop) {
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('drop', handleDrop)
}
})
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
addFiles(input.files)
}
input.value = ''
}
function onSubmit(e: Event) {
e.preventDefault()
submitForm()
}
</script>
<template>
<div>
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="accept"
:multiple="multiple"
@change="onFileChange"
>
<form
ref="formRef"
:class="cn('w-full', props.class)"
@submit="onSubmit"
@dragover.prevent="handleDragOver"
@drop.prevent="handleDrop"
>
<InputGroup class="overflow-hidden">
<slot />
</InputGroup>
</form>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { InputGroupTextarea } from '@repo/shadcn-vue/components/ui/input-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { computed, ref } from 'vue'
import { usePromptInput } from './context'
type PromptInputTextareaProps = InstanceType<typeof InputGroupTextarea>['$props']
interface Props extends /* @vue-ignore */ PromptInputTextareaProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const { textInput, setTextInput, submitForm, addFiles, files, removeFile } = usePromptInput()
const isComposing = ref(false)
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
if (isComposing.value || e.shiftKey)
return
e.preventDefault()
submitForm()
}
// Remove last attachment on backspace if input is empty
if (e.key === 'Backspace' && textInput.value === '' && files.value.length > 0) {
const lastFile = files.value[files.value.length - 1]
if (lastFile) {
removeFile(lastFile.id)
}
}
}
function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items)
return
const pastedFiles: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file)
pastedFiles.push(file)
}
}
if (pastedFiles.length > 0) {
e.preventDefault()
addFiles(pastedFiles)
}
}
const modelValue = computed({
get: () => textInput.value,
set: val => setTextInput(val),
})
</script>
<template>
<InputGroupTextarea
v-model="modelValue"
placeholder="What would you like to know?"
name="message"
:class="cn('field-sizing-content max-h-48 min-h-16', props.class)"
v-bind="props"
@keydown="handleKeyDown"
@paste="handlePaste"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div :class="cn('flex items-center gap-1', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
// import type { InputGroupButtonVariants } from '@repo/shadcn-vue/components/ui/input-group'
import type { ChatStatus } from 'ai'
import type { HTMLAttributes } from 'vue'
import { InputGroupButton } from '@repo/shadcn-vue/components/ui/input-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { CornerDownLeftIcon, Loader2Icon, SquareIcon, XIcon } from 'lucide-vue-next'
import { computed } from 'vue'
type InputGroupButtonProps = InstanceType<typeof InputGroupButton>['$props']
interface Props extends /* @vue-ignore */ InputGroupButtonProps {
class?: HTMLAttributes['class']
status?: ChatStatus
variant?: InputGroupButtonProps['variant']
size?: InputGroupButtonProps['size']
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'icon-sm',
})
const icon = computed(() => {
if (props.status === 'submitted') {
return Loader2Icon
}
else if (props.status === 'streaming') {
return SquareIcon
}
else if (props.status === 'error') {
return XIcon
}
return CornerDownLeftIcon
})
const iconClass = computed(() => {
if (props.status === 'submitted') {
return 'size-4 animate-spin'
}
return 'size-4'
})
const { status, size, variant, class: _, ...restProps } = props
</script>
<template>
<InputGroupButton
aria-label="Submit"
:class="cn(props.class)"
:size="size"
:variant="variant"
type="submit"
v-bind="restProps"
>
<slot>
<component :is="icon" :class="iconClass" />
</slot>
</InputGroupButton>
</template>
<script setup lang="ts">
import type { ChatStatus } from 'ai'
import type { HTMLAttributes } from 'vue'
import { InputGroupButton } from '@repo/shadcn-vue/components/ui/input-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { CornerDownLeftIcon, Loader2Icon, SquareIcon, XIcon } from 'lucide-vue-next'
import { computed } from 'vue'
type InputGroupButtonProps = InstanceType<typeof InputGroupButton>['$props']
interface Props extends /* @vue-ignore */ InputGroupButtonProps {
class?: HTMLAttributes['class']
status?: ChatStatus
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'icon-sm',
})
const icon = computed(() => {
if (props.status === 'submitted') {
return Loader2Icon
}
else if (props.status === 'streaming') {
return SquareIcon
}
else if (props.status === 'error') {
return XIcon
}
return CornerDownLeftIcon
})
const iconClass = computed(() => {
if (props.status === 'submitted') {
return 'size-4 animate-spin'
}
return 'size-4'
})
const { status, size, variant, class: _, ...restProps } = props
</script>
<template>
<InputGroupButton
aria-label="Submit"
:class="cn(props.class)"
:size="size"
:variant="variant"
type="submit"
v-bind="restProps"
>
<slot>
<component :is="icon" :class="iconClass" />
</slot>
</InputGroupButton>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div :class="cn('contents', props.class)" v-bind="props">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { usePromptInput } from './context'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { files } = usePromptInput()
</script>
<template>
<div
v-if="files.length > 0"
:class="cn('flex flex-wrap items-center gap-2 p-3 w-full', props.class)"
>
<template v-for="file in files" :key="file.id">
<slot :file="file" />
</template>
</div>
</template>
<script setup lang="ts">
import type { AttachmentFile } from './types'
import { Button } from '@repo/shadcn-vue/components/ui/button'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@repo/shadcn-vue/components/ui/hover-card'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { PaperclipIcon, XIcon } from 'lucide-vue-next'
import { computed } from 'vue'
import { usePromptInput } from './context'
const props = defineProps<{
file: AttachmentFile
class?: string
}>()
const { removeFile } = usePromptInput()
const filename = computed(() => props.file.filename || '')
const isImage = computed(() =>
props.file.mediaType?.startsWith('image/') && props.file.url,
)
const label = computed(() => filename.value || (isImage.value ? 'Image' : 'Attachment'))
function handleRemove(e: Event) {
e.stopPropagation()
removeFile(props.file.id)
}
</script>
<template>
<HoverCard :open-delay="0" :close-delay="0">
<HoverCardTrigger as-child>
<div
:class="cn(
'group relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
props.class,
)"
>
<div class="relative size-5 shrink-0">
<div class="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
<img
v-if="isImage"
:src="file.url"
:alt="label"
class="size-5 object-cover"
>
<div v-else class="flex size-5 items-center justify-center text-muted-foreground">
<PaperclipIcon class="size-3" />
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
@click="handleRemove"
>
<XIcon />
<span class="sr-only">Remove</span>
</Button>
</div>
<span class="flex-1 truncate max-w-[150px]">{{ label }}</span>
</div>
</HoverCardTrigger>
<HoverCardContent class="w-auto p-2" align="start">
<div class="w-auto space-y-3">
<div v-if="isImage" class="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
<img
:src="file.url"
:alt="label"
class="max-h-full max-w-full object-contain"
>
</div>
<div class="flex items-center gap-2.5">
<div class="min-w-0 flex-1 space-y-1 px-0.5">
<h4 class="truncate font-semibold text-sm leading-none">
{{ label }}
</h4>
<p v-if="file.mediaType" class="truncate font-mono text-muted-foreground text-xs">
{{ file.mediaType }}
</p>
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { InputGroupAddon } from '@repo/shadcn-vue/components/ui/input-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputHeaderProps = InstanceType<typeof InputGroupAddon>['$props']
interface Props extends /* @vue-ignore */ Omit<PromptInputHeaderProps, 'align'> {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<InputGroupAddon
align="block-end"
:class="cn('order-first flex-wrap gap-1', props.class)"
v-bind="props"
>
<slot />
</InputGroupAddon>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { InputGroupAddon } from '@repo/shadcn-vue/components/ui/input-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputFooterProps = InstanceType<typeof InputGroupAddon>['$props']
interface Props extends /* @vue-ignore */ Omit<PromptInputFooterProps, 'align'> {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<InputGroupAddon
align="block-end"
:class="cn('justify-between gap-1', props.class)"
v-bind="props"
>
<slot />
</InputGroupAddon>
</template>
<script setup lang="ts">
import { DropdownMenu } from '@repo/shadcn-vue/components/ui/dropdown-menu'
type DropdownMenuProps = InstanceType<typeof DropdownMenu>['$props']
interface Props extends /* @vue-ignore */ DropdownMenuProps {}
const props = defineProps<Props>()
</script>
<template>
<DropdownMenu v-bind="props">
<slot />
</DropdownMenu>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DropdownMenuTrigger } from '@repo/shadcn-vue/components/ui/dropdown-menu'
import { PlusIcon } from 'lucide-vue-next'
import PromptInputButton from './PromptInputButton.vue'
type DropdownMenuTriggerProps = InstanceType<typeof DropdownMenuTrigger>['$props']
interface Props extends /* @vue-ignore */ DropdownMenuTriggerProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<DropdownMenuTrigger as-child>
<PromptInputButton :class="props.class" v-bind="props">
<slot><PlusIcon class="size-4" /></slot>
</PromptInputButton>
</DropdownMenuTrigger>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DropdownMenuContent } from '@repo/shadcn-vue/components/ui/dropdown-menu'
import { cn } from '@repo/shadcn-vue/lib/utils'
type DropdownMenuContentProps = InstanceType<typeof DropdownMenuContent>['$props']
interface Props extends /* @vue-ignore */ DropdownMenuContentProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const { align, class: _, ...restProps } = props
</script>
<template>
<DropdownMenuContent align="start" :class="cn(props.class)" v-bind="restProps">
<slot />
</DropdownMenuContent>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DropdownMenuItem } from '@repo/shadcn-vue/components/ui/dropdown-menu'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputActionMenuItemProps = InstanceType<typeof DropdownMenuItem>['$props']
interface Props extends /* @vue-ignore */ PromptInputActionMenuItemProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<DropdownMenuItem :class="cn(props.class)">
<slot />
</DropdownMenuItem>
</template>
<script setup lang="ts">
import { DropdownMenuItem } from '@repo/shadcn-vue/components/ui/dropdown-menu'
import { ImageIcon } from 'lucide-vue-next'
import { usePromptInput } from './context'
defineProps<{ label?: string }>()
const { openFileDialog } = usePromptInput()
</script>
<template>
<DropdownMenuItem @select.prevent="openFileDialog">
<ImageIcon class="mr-2 size-4" />
{{ label || 'Add photos or files' }}
</DropdownMenuItem>
</template>
<script setup lang="ts">
import { InputGroupButton } from '@repo/shadcn-vue/components/ui/input-group'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { MicIcon } from 'lucide-vue-next'
import { onMounted, onUnmounted, ref } from 'vue'
import { usePromptInput } from './context'
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
start: () => void
stop: () => void
onstart: ((this: SpeechRecognition, ev: Event) => any) | null
onend: ((this: SpeechRecognition, ev: Event) => any) | null
onresult: ((this: SpeechRecognition, ev: any) => any) | null
onerror: ((this: SpeechRecognition, ev: any) => any) | null
}
const props = defineProps<{ class?: string }>()
const { textInput, setTextInput } = usePromptInput()
const isListening = ref(false)
const recognition = ref<SpeechRecognition | null>(null)
onMounted(() => {
const Win = window as any
const SpeechRecognition = Win.SpeechRecognition || Win.webkitSpeechRecognition
if (SpeechRecognition) {
const sr = new SpeechRecognition()
sr.continuous = true
sr.interimResults = true
sr.lang = 'en-US'
sr.onstart = () => isListening.value = true
sr.onend = () => isListening.value = false
sr.onresult = (event: any) => {
let finalTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (result.isFinal) {
finalTranscript += result[0]?.transcript ?? ''
}
}
if (finalTranscript) {
const newValue = textInput.value + (textInput.value ? ' ' : '') + finalTranscript
setTextInput(newValue)
}
}
sr.onerror = (event: any) => {
console.error('Speech recognition error:', event.error)
isListening.value = false
}
recognition.value = sr
}
})
onUnmounted(() => {
recognition.value?.stop()
})
function toggleListening() {
if (!recognition.value)
return
if (isListening.value) {
recognition.value.stop()
}
else {
recognition.value.start()
}
}
</script>
<template>
<InputGroupButton
type="button"
variant="ghost"
size="icon-sm"
:disabled="!recognition"
:class="cn(
'relative transition-all duration-200',
isListening && 'animate-pulse bg-accent text-accent-foreground',
props.class,
)"
@click="toggleListening"
>
<MicIcon class="size-4" />
</InputGroupButton>
</template>
<script setup lang="ts">
import { Select } from '@repo/shadcn-vue/components/ui/select'
type PromptInputSelectProps = InstanceType<typeof Select>['$props']
interface Props extends /* @vue-ignore */ PromptInputSelectProps {}
const props = defineProps<Props>()
</script>
<template>
<Select v-bind="props">
<slot />
</Select>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { SelectTrigger } from '@repo/shadcn-vue/components/ui/select'
import { cn } from '@repo/shadcn-vue/lib/utils'
type SelectTriggerProps = InstanceType<typeof SelectTrigger>['$props']
interface Props extends /* @vue-ignore */ SelectTriggerProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<SelectTrigger
:class="cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground',
props.class,
)"
v-bind="props"
>
<slot />
</SelectTrigger>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { SelectContent } from '@repo/shadcn-vue/components/ui/select'
import { cn } from '@repo/shadcn-vue/lib/utils'
type SelectContentProps = InstanceType<typeof SelectContent>['$props']
interface Props extends /* @vue-ignore */ SelectContentProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<SelectContent :class="cn(props.class)" v-bind="props">
<slot />
</SelectContent>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { SelectItem } from '@repo/shadcn-vue/components/ui/select'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputSelectItemProps = InstanceType<typeof SelectItem>['$props']
interface Props extends /* @vue-ignore */ PromptInputSelectItemProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<SelectItem :class="cn(props.class)" v-bind="props">
<slot />
</SelectItem>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { SelectValue } from '@repo/shadcn-vue/components/ui/select'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputSelectValueProps = InstanceType<typeof SelectValue>['$props']
interface Props extends /* @vue-ignore */ PromptInputSelectValueProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<SelectValue :class="cn(props.class)" v-bind="props">
<slot />
</SelectValue>
</template>
<script setup lang="ts">
import { HoverCard } from '@repo/shadcn-vue/components/ui/hover-card'
type PromptInputHoverCardProps = InstanceType<typeof HoverCard>['$props']
interface Props extends /* @vue-ignore */ PromptInputHoverCardProps {
openDelay?: number
closeDelay?: number
}
const props = withDefaults(defineProps<Props>(), {
openDelay: 0,
closeDelay: 0,
})
const { openDelay, closeDelay, ...restProps } = props
</script>
<template>
<HoverCard :open-delay="openDelay" :close-delay="closeDelay" v-bind="restProps">
<slot />
</HoverCard>
</template>
<script setup lang="ts">
import { HoverCardTrigger } from '@repo/shadcn-vue/components/ui/hover-card'
type PromptInputHoverCardTriggerProps = InstanceType<typeof HoverCardTrigger>['$props']
interface Props extends /* @vue-ignore */ PromptInputHoverCardTriggerProps {}
const props = defineProps<Props>()
</script>
<template>
<HoverCardTrigger v-bind="props">
<slot />
</HoverCardTrigger>
</template>
<script setup lang="ts">
import { HoverCardContent } from '@repo/shadcn-vue/components/ui/hover-card'
type PromptInputHoverCardContentProps = InstanceType<typeof HoverCardContent>['$props']
interface Props extends /* @vue-ignore */ PromptInputHoverCardContentProps {}
const props = withDefaults(defineProps<Props>(), {
align: 'start',
})
const { align, ...restProps } = props
</script>
<template>
<HoverCardContent :align="props.align" v-bind="restProps">
<slot />
</HoverCardContent>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props extends /* @vue-ignore */ HTMLAttributes {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn(props.class)" v-bind="props">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props extends /* @vue-ignore */ HTMLAttributes {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn(props.class)" v-bind="props">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props extends /* @vue-ignore */ HTMLAttributes {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<h3 :class="cn('mb-2 px-3 font-medium text-muted-foreground text-xs', props.class)" v-bind="props">
<slot />
</h3>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props extends /* @vue-ignore */ HTMLAttributes {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('space-y-1', props.class)" v-bind="props">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
interface Props extends /* @vue-ignore */ HTMLAttributes {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent', props.class)" v-bind="props">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Command } from '@repo/shadcn-vue/components/ui/command'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputCommandProps = InstanceType<typeof Command>['$props']
interface Props extends /* @vue-ignore */ PromptInputCommandProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<Command :class="cn(props.class)" v-bind="props">
<slot />
</Command>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CommandInput } from '@repo/shadcn-vue/components/ui/command'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputCommandInputProps = InstanceType<typeof CommandInput>['$props']
interface Props extends /* @vue-ignore */ PromptInputCommandInputProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<CommandInput :class="cn(props.class)" v-bind="props" />
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CommandList } from '@repo/shadcn-vue/components/ui/command'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputCommandListProps = InstanceType<typeof CommandList>['$props']
interface Props extends /* @vue-ignore */ PromptInputCommandListProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<CommandList :class="cn(props.class)" v-bind="props" />
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CommandEmpty } from '@repo/shadcn-vue/components/ui/command'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputCommandEmptyProps = InstanceType<typeof CommandEmpty>['$props']
interface Props extends /* @vue-ignore */ PromptInputCommandEmptyProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<CommandEmpty :class="cn(props.class)" v-bind="props">
<slot />
</CommandEmpty>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CommandGroup } from '@repo/shadcn-vue/components/ui/command'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputCommandGroupProps = InstanceType<typeof CommandGroup>['$props']
interface Props extends /* @vue-ignore */ PromptInputCommandGroupProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<CommandGroup :class="cn(props.class)" v-bind="props">
<slot />
</CommandGroup>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CommandItem } from '@repo/shadcn-vue/components/ui/command'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputCommandItemProps = InstanceType<typeof CommandItem>['$props']
interface Props extends /* @vue-ignore */ PromptInputCommandItemProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<CommandItem v-bind="props" :class="cn(props.class)">
<slot />
</CommandItem>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CommandSeparator } from '@repo/shadcn-vue/components/ui/command'
import { cn } from '@repo/shadcn-vue/lib/utils'
type PromptInputCommandSeparatorProps = InstanceType<typeof CommandSeparator>['$props']
interface Props extends /* @vue-ignore */ PromptInputCommandSeparatorProps {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<CommandSeparator :class="cn(props.class)" v-bind="props" />
</template>
import type { AttachmentFile, PromptInputContext } from './types'
import { nanoid } from 'nanoid'
import { inject, onBeforeUnmount, provide, ref } from 'vue'
import { PROMPT_INPUT_KEY } from './types'
export function usePromptInputProvider(props: {
initialInput?: string
maxFiles?: number
maxFileSize?: number
accept?: string
onSubmit?: (message: { text: string, files: any[] }) => void | Promise<void>
onError?: (err: { code: string, message: string }) => void
}) {
const textInput = ref(props.initialInput || '')
const files = ref<AttachmentFile[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const isLoading = ref(false)
// Cleanup object URLs to avoid memory leaks
onBeforeUnmount(() => {
files.value.forEach((f) => {
if (f.url && f.url.startsWith('blob:')) {
URL.revokeObjectURL(f.url)
}
})
})
const setTextInput = (val: string) => {
textInput.value = val
}
const matchesAccept = (file: File) => {
if (!props.accept || props.accept.trim() === '')
return true
if (props.accept.includes('image/*'))
return file.type.startsWith('image/')
// Add more mime-type checks here if necessary
return true
}
const addFiles = (incoming: File[] | FileList) => {
const fileList = Array.from(incoming)
// Validate Accept
const accepted = fileList.filter(matchesAccept)
if (fileList.length && accepted.length === 0) {
props.onError?.({ code: 'accept', message: 'No files match the accepted types.' })
return
}
// Validate Size
const withinSize = (f: File) => (props.maxFileSize ? f.size <= props.maxFileSize : true)
const sized = accepted.filter(withinSize)
if (accepted.length > 0 && sized.length === 0) {
props.onError?.({ code: 'max_file_size', message: 'All files exceed the maximum size.' })
return
}
// Validate Count
const currentCount = files.value.length
const capacity = props.maxFiles ? Math.max(0, props.maxFiles - currentCount) : undefined
const capped = typeof capacity === 'number' ? sized.slice(0, capacity) : sized
if (typeof capacity === 'number' && sized.length > capacity) {
props.onError?.({ code: 'max_files', message: 'Too many files. Some were not added.' })
}
const newAttachments: AttachmentFile[] = capped.map(file => ({
id: nanoid(),
type: 'file',
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
}))
files.value = [...files.value, ...newAttachments]
}
const removeFile = (id: string) => {
const file = files.value.find(f => f.id === id)
if (file?.url && file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url)
}
files.value = files.value.filter(f => f.id !== id)
}
const clearFiles = () => {
files.value.forEach((f) => {
if (f.url && f.url.startsWith('blob:')) {
URL.revokeObjectURL(f.url)
}
})
files.value = []
}
const clearInput = () => {
textInput.value = ''
}
const openFileDialog = () => {
fileInputRef.value?.click()
}
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
try {
const response = await fetch(url)
const blob = await response.blob()
return new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = () => resolve(null)
reader.readAsDataURL(blob)
})
}
catch {
return null
}
}
const submitForm = async () => {
if (!props.onSubmit)
return
// Process files (convert blobs to base64 if needed for AI SDK)
const processedFiles = await Promise.all(
files.value.map(async (item) => {
if (item.url && item.url.startsWith('blob:')) {
const dataUrl = await convertBlobUrlToDataUrl(item.url)
return { ...item, url: dataUrl ?? item.url }
}
return item
}),
)
const message = {
text: textInput.value,
files: processedFiles,
}
try {
isLoading.value = true
const result = props.onSubmit(message)
if (result instanceof Promise) {
await result
}
clearInput()
clearFiles()
}
catch (e) {
if (props.onError) {
const errorMessage = e instanceof Error
? e.message
: String(e) || 'An unknown error occurred during submission.'
props.onError({
code: 'submit_error',
message: errorMessage,
})
}
console.error('Submission failed:', e)
}
finally {
isLoading.value = false
}
}
const context: PromptInputContext = {
textInput,
files,
fileInputRef,
isLoading,
setTextInput,
addFiles,
removeFile,
clearFiles,
clearInput,
openFileDialog,
submitForm,
}
provide(PROMPT_INPUT_KEY, context)
return context
}
export function usePromptInput() {
const context = inject<PromptInputContext>(PROMPT_INPUT_KEY)
if (!context) {
throw new Error('usePromptInput must be used within a PromptInput component')
}
return context
}
import type { FileUIPart } from 'ai'
import type { Ref } from 'vue'
export interface PromptInputMessage {
text: string
files: FileUIPart[]
}
export interface AttachmentFile extends FileUIPart {
id: string
file?: File
}
export interface PromptInputContext {
textInput: Ref<string>
files: Ref<AttachmentFile[]>
isLoading: Ref<boolean>
fileInputRef: Ref<HTMLInputElement | null>
setTextInput: (val: string) => void
addFiles: (files: File[] | FileList) => void
removeFile: (id: string) => void
clearFiles: () => void
clearInput: () => void
openFileDialog: () => void
submitForm: () => void
}
export const PROMPT_INPUT_KEY = Symbol('PromptInputContext')
export * from './context'
export { default as PromptInput } from './PromptInput.vue'
export { default as PromptInputActionAddAttachments } from './PromptInputActionAddAttachments.vue'
export { default as PromptInputActionMenu } from './PromptInputActionMenu.vue'
export { default as PromptInputActionMenuContent } from './PromptInputActionMenuContent.vue'
export { default as PromptInputActionMenuItem } from './PromptInputActionMenuItem.vue'
export { default as PromptInputActionMenuTrigger } from './PromptInputActionMenuTrigger.vue'
export { default as PromptInputAttachment } from './PromptInputAttachment.vue'
export { default as PromptInputAttachments } from './PromptInputAttachments.vue'
export { default as PromptInputBody } from './PromptInputBody.vue'
export { default as PromptInputButton } from './PromptInputButton.vue'
export { default as PromptInputCommand } from './PromptInputCommand.vue'
export { default as PromptInputCommandEmpty } from './PromptInputCommandEmpty.vue'
export { default as PromptInputCommandGroup } from './PromptInputCommandGroup.vue'
export { default as PromptInputCommandInput } from './PromptInputCommandInput.vue'
export { default as PromptInputCommandItem } from './PromptInputCommandItem.vue'
export { default as PromptInputCommandList } from './PromptInputCommandList.vue'
export { default as PromptInputCommandSeparator } from './PromptInputCommandSeparator.vue'
export { default as PromptInputFooter } from './PromptInputFooter.vue'
export { default as PromptInputHeader } from './PromptInputHeader.vue'
export { default as PromptInputHoverCard } from './PromptInputHoverCard.vue'
export { default as PromptInputHoverCardContent } from './PromptInputHoverCardContent.vue'
export { default as PromptInputHoverCardTrigger } from './PromptInputHoverCardTrigger.vue'
export { default as PromptInputProvider } from './PromptInputProvider.vue'
export { default as PromptInputSelect } from './PromptInputSelect.vue'
export { default as PromptInputSelectContent } from './PromptInputSelectContent.vue'
export { default as PromptInputSelectItem } from './PromptInputSelectItem.vue'
export { default as PromptInputSelectTrigger } from './PromptInputSelectTrigger.vue'
export { default as PromptInputSelectValue } from './PromptInputSelectValue.vue'
export { default as PromptInputSpeechButton } from './PromptInputSpeechButton.vue'
export { default as PromptInputSubmit } from './PromptInputSubmit.vue'
export { default as PromptInputTab } from './PromptInputTab.vue'
export { default as PromptInputTabBody } from './PromptInputTabBody.vue'
export { default as PromptInputTabItem } from './PromptInputTabItem.vue'
export { default as PromptInputTabLabel } from './PromptInputTabLabel.vue'
export { default as PromptInputTabsList } from './PromptInputTabsList.vue'
export { default as PromptInputTextarea } from './PromptInputTextarea.vue'
export { default as PromptInputTools } from './PromptInputTools.vue'
export * from './types'
与 AI SDK 一起使用
使用 PromptInput、Conversation 和模型选择器构建一个功能完整的聊天应用:
将以下组件添加到你的前端:
pages/index.vue
<script setup lang="ts">
import type { PromptInputMessage } from '@/components/ai-elements/prompt-input'
import { useChat } from '@ai-sdk/vue'
import { GlobeIcon } from 'lucide-vue-next'
import { ref } from 'vue'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
PromptInputHeader,
PromptInputSelect,
PromptInputSelectContent,
PromptInputSelectItem,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSpeechButton,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools
} from '@/components/ai-elements/prompt-input'
const models = [
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'claude-opus-4-20250514', name: 'Claude 4 Opus' },
]
const text = ref<string>('')
const model = ref<string>(models[0].id)
const useWebSearch = ref<boolean>(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const { messages, status, sendMessage } = useChat()
function handleSubmit(message: PromptInputMessage) {
const hasText = Boolean(message.text)
const hasAttachments = Boolean(message.files?.length)
if (!(hasText || hasAttachments)) {
return
}
sendMessage(
{
text: message.text || 'Sent with attachments',
files: message.files
},
{
body: {
model: model.value,
webSearch: useWebSearch.value,
},
},
)
text.value = ''
}
</script>
<template>
<div class="max-w-4xl mx-auto p-6 relative size-full rounded-lg border h-[600px]">
<div class="flex flex-col h-full">
<Conversation>
<ConversationContent>
<Message
v-for="message in messages"
:key="message.id"
:from="message.role"
>
<MessageContent>
<template v-for="(part, i) in message.parts" :key="`${message.id}-${i}`">
<MessageResponse v-if="part.type === 'text'">
{{ part.text }}
</MessageResponse>
</template>
</MessageContent>
</Message>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput
class="mt-4"
global-drop
multiple
@submit="handleSubmit"
>
<PromptInputHeader>
<PromptInputAttachments>
<template #default="{ attachment }">
<PromptInputAttachment :data="attachment" />
</template>
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
ref="textareaRef"
:model-value="text"
@update:model-value="text = $event"
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
<PromptInputSpeechButton
:textarea-ref="textareaRef?.$el || null"
@transcription-change="text = $event"
/>
<PromptInputButton
:variant="useWebSearch ? 'default' : 'ghost'"
@click="useWebSearch = !useWebSearch"
>
<GlobeIcon :size="16" />
<span>Search</span>
</PromptInputButton>
<PromptInputSelect v-model="model">
<PromptInputSelectTrigger>
<PromptInputSelectValue />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
<PromptInputSelectItem
v-for="m in models"
:key="m.id"
:value="m.id"
>
{{ m.name }}
</PromptInputSelectItem>
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit
:disabled="!text && !status"
:status="status"
/>
</PromptInputFooter>
</PromptInput>
</div>
</div>
</template>
将以下路由添加到你的后端:
server/api/chat.ts
import { convertToModelMessages, streamText, UIMessage } from 'ai'
// Allow streaming responses up to 30 seconds
export const maxDuration = 30
export default defineEventHandler(async (event) => {
const {
model,
messages,
webSearch
}: {
messages: UIMessage[]
model: string
webSearch?: boolean
} = await readBody(event)
const result = streamText({
model: webSearch ? 'perplexity/sonar' : model,
messages: convertToModelMessages(messages),
})
return result.toUIMessageStreamResponse()
})
特性
- 根据内容自动调整高度的自动调整大小的文本区域
- 支持拖放的文件附件
- 图片附件的图片预览
- 可配置的文件约束(最大文件数、最大大小、接受的类型)
- 基于状态的自动提交按钮图标
- 支持键盘快捷键(Enter 提交,Shift+Enter 换行)
- 可自定义的文本区域最小/最大高度
- 灵活的工具栏,支持自定义操作和工具
- 内置模型选择下拉菜单
- 内置原生语音识别按钮(Web Speech API)
- 可选的提供者,用于提升状态管理
- 提交时表单自动重置
- 响应式设计,具有移动友好的控件
- 简洁、现代的样式,具有可自定义的主题
- 基于表单的提交处理
- 隐藏的文件输入同步,用于原生表单提交
- 全局文档拖放支持(可选)
示例
光标样式
Props
<PromptInputProvider />
initialInputstring
<PromptInput />
acceptstring
multipleboolean
globalDropboolean
maxFilesnumber
maxFileSizenumber
...propsHTMLAttributes
<PromptInputTextarea />
...propsInstanceType<typeof InputGroupTextarea>
<PromptInputTools />
...propsHTMLAttributes
<PromptInputButton />
variant'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
'ghost'size'default' | 'sm' | 'lg' | 'icon'
auto...propsInstanceType<typeof InputGroupButton>
<PromptInputSubmit />
statusChatStatus
variant'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
'default'size'default' | 'sm' | 'lg' | 'icon'
'icon'...propsInstanceType<typeof InputGroupButton>
<PromptInputBody />
...propsHTMLAttributes
<PromptInputAttachments />
...propsHTMLAttributes
<PromptInputAttachment />
fileAttachmentFile
...propsHTMLAttributes
<PromptInputHeader />
...props InstanceType<typeof InputGroupAddon>
<PromptInputFooter />
...props InstanceType<typeof InputGroupAddon>
<PromptInputActionMenu />
...props InstanceType<typeof DropdownMenu>
<PromptInputActionMenuTrigger />
...props InstanceType<typeof DropdownMenuTrigger>
<PromptInputActionMenuContent />
...props InstanceType<typeof DropdownMenuContent>
<PromptInputActionMenuItem />
...props InstanceType<typeof DropdownMenuItem>
<PromptInputActionAddAttachments />
labelstring
...props InstanceType<typeof DropdownMenuItem>
<PromptInputSpeechButton />
...props InstanceType<typeof PromptInputButton >
<PromptInputSelect />
...props InstanceType<typeof Select>
<PromptInputSelectTrigger />
...propsInstanceType<typeof SelectTrigger>
<PromptInputSelectContent />
...propsInstanceType<typeof SelectContent>
<PromptInputSelectItem />
...propsInstanceType<typeof SelectItem>
<PromptInputSelectValue />
...propsInstanceType<typeof SelectValue>
<PromptInputHoverCard />
openDelaynumber
0closeDelaynumber
0...propsInstanceType<typeof HoverCard>
<PromptInputHoverCardTrigger />
...propsInstanceType<typeof HoverCardTrigger>
<PromptInputHoverCardContent />
align'start' | 'center' | 'end'
'start'...propsInstanceType<typeof HoverCardContent>
<PromptInputTabsList />
...propsHTMLAttributes
<PromptInputTab />
...propsHTMLAttributes
<PromptInputTabLabel />
...propsHTMLAttributes
<PromptInputTabBody />
...propsHTMLAttributes
<PromptInputTabItem />
...propsHTMLAttributes
<PromptInputCommand />
...propsInstanceType<typeof Command>
<PromptInputCommandInput />
...propsInstanceType<typeof CommandInput>
<PromptInputCommandList />
...propsInstanceType<typeof CommandList>
<PromptInputCommandEmpty />
...propsInstanceType<typeof CommandEmpty>
<PromptInputCommandGroup />
...propsInstanceType<typeof CommandGroup>
<PromptInputCommandItem />
...propsInstanceType<typeof CommandItem>
<PromptInputCommandSeparator />
...propsInstanceType<typeof CommandSeparator>
On this page