Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,107 changes: 1,811 additions & 296 deletions apps/www/content/3.components/1.chatbot/prompt-input.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/www/content/3.components/1.chatbot/suggestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ function handleSuggestionClick(suggestion: string) {

### Usage with AI Input

:::ComponentLoader{label="Preview" componentName="SuggestionAiInput"}
:::ComponentLoader{label="Preview" componentName="SuggestionInput"}
:::

## Props
Expand Down
116 changes: 104 additions & 12 deletions packages/elements/src/prompt-input/PromptInput.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,113 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
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'

interface Props {
class?: string
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)
}
}

const props = defineProps<Props>()
const attrs = useAttrs()
onMounted(() => {
if (props.globalDrop) {
document.addEventListener('dragover', handleDragOver)
document.addEventListener('drop', handleDrop)
}
})

onUnmounted(() => {
if (props.globalDrop) {
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('drop', handleDrop)
}
})

const classes = computed(() => [
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
props.class,
])
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>
<form :class="classes" v-bind="attrs">
<slot />
</form>
<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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { DropdownMenuItem } from '@repo/shadcn-vue/components/ui/dropdown-menu'
import { ImageIcon } from 'lucide-vue-next'
import { usePromptInput } from './context'

type PromptInputActionAddAttachmentsProps = InstanceType<typeof DropdownMenuItem>['$props']

interface Props extends /* @vue-ignore */ PromptInputActionAddAttachmentsProps {
label?: string
}

const props = defineProps<Props>()

const { openFileDialog } = usePromptInput()
</script>

<template>
<DropdownMenuItem @select.prevent="openFileDialog">
<ImageIcon class="mr-2 size-4" />
{{ props.label || 'Add photos or files' }}
</DropdownMenuItem>
</template>
15 changes: 15 additions & 0 deletions packages/elements/src/prompt-input/PromptInputActionMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<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>
19 changes: 19 additions & 0 deletions packages/elements/src/prompt-input/PromptInputActionMenuItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<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>
93 changes: 93 additions & 0 deletions packages/elements/src/prompt-input/PromptInputAttachment.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<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>
22 changes: 22 additions & 0 deletions packages/elements/src/prompt-input/PromptInputAttachments.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<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>
12 changes: 12 additions & 0 deletions packages/elements/src/prompt-input/PromptInputBody.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<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>
Loading