Skip to content

Commit fe5f099

Browse files
peoraycwandev
andauthored
feat: new prompt-input component (#63)
* feat: update prompt input component * fix: state management into a new context module and remove model selection components * fix: queue and suggestion examples to use the updated prompt-input components * feat: prompt-input now manages its own context making the need for an external provider optional * chore(docs): update prompt-input component docs * chore: update prompt-input.md * chore(shadcn-vue): update input component --------- Co-authored-by: cwandev <18888351756@163.com>
1 parent af862bd commit fe5f099

File tree

62 files changed

+4095
-656
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+4095
-656
lines changed

apps/www/content/3.components/1.chatbot/prompt-input.md

Lines changed: 1811 additions & 296 deletions
Large diffs are not rendered by default.

apps/www/content/3.components/1.chatbot/suggestion.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ function handleSuggestionClick(suggestion: string) {
191191

192192
### Usage with AI Input
193193

194-
:::ComponentLoader{label="Preview" componentName="SuggestionAiInput"}
194+
:::ComponentLoader{label="Preview" componentName="SuggestionInput"}
195195
:::
196196

197197
## Props
Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,113 @@
11
<script setup lang="ts">
2-
import { computed, useAttrs } from 'vue'
2+
import type { HTMLAttributes } from 'vue'
3+
import type { PromptInputMessage } from './types'
4+
import { InputGroup } from '@repo/shadcn-vue/components/ui/input-group'
5+
import { cn } from '@repo/shadcn-vue/lib/utils'
6+
import { inject, onMounted, onUnmounted, ref } from 'vue'
7+
import { usePromptInputProvider } from './context'
8+
import { PROMPT_INPUT_KEY } from './types'
39
4-
interface Props {
5-
class?: string
10+
const props = defineProps<{
11+
class?: HTMLAttributes['class']
12+
accept?: string
13+
multiple?: boolean
14+
globalDrop?: boolean
15+
maxFiles?: number
16+
maxFileSize?: number
17+
initialInput?: string
18+
}>()
19+
20+
const emit = defineEmits<{
21+
(e: 'submit', payload: PromptInputMessage): void
22+
(e: 'error', payload: { code: string, message: string }): void
23+
}>()
24+
25+
const formRef = ref<HTMLFormElement | null>(null)
26+
27+
// --- Dual-mode context handling ---
28+
const inheritedContext = inject(PROMPT_INPUT_KEY, null)
29+
const localContext = inheritedContext
30+
? null
31+
: usePromptInputProvider({
32+
initialInput: props.initialInput,
33+
maxFiles: props.maxFiles,
34+
maxFileSize: props.maxFileSize,
35+
accept: props.accept,
36+
onSubmit: msg => emit('submit', msg as any),
37+
onError: err => emit('error', err),
38+
})
39+
40+
const context = inheritedContext || localContext
41+
42+
if (!context) {
43+
throw new Error('PromptInput context is missing.')
44+
}
45+
46+
const { fileInputRef, addFiles, submitForm } = context
47+
48+
function handleDragOver(e: DragEvent) {
49+
if (e.dataTransfer?.types?.includes('Files')) {
50+
e.preventDefault()
51+
}
52+
}
53+
54+
function handleDrop(e: DragEvent) {
55+
if (e.dataTransfer?.types?.includes('Files')) {
56+
e.preventDefault()
57+
}
58+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
59+
addFiles(e.dataTransfer.files)
60+
}
661
}
762
8-
const props = defineProps<Props>()
9-
const attrs = useAttrs()
63+
onMounted(() => {
64+
if (props.globalDrop) {
65+
document.addEventListener('dragover', handleDragOver)
66+
document.addEventListener('drop', handleDrop)
67+
}
68+
})
69+
70+
onUnmounted(() => {
71+
if (props.globalDrop) {
72+
document.removeEventListener('dragover', handleDragOver)
73+
document.removeEventListener('drop', handleDrop)
74+
}
75+
})
1076
11-
const classes = computed(() => [
12-
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
13-
props.class,
14-
])
77+
function onFileChange(e: Event) {
78+
const input = e.target as HTMLInputElement
79+
if (input.files) {
80+
addFiles(input.files)
81+
}
82+
input.value = ''
83+
}
84+
85+
function onSubmit(e: Event) {
86+
e.preventDefault()
87+
submitForm()
88+
}
1589
</script>
1690

1791
<template>
18-
<form :class="classes" v-bind="attrs">
19-
<slot />
20-
</form>
92+
<div>
93+
<input
94+
ref="fileInputRef"
95+
type="file"
96+
class="hidden"
97+
:accept="accept"
98+
:multiple="multiple"
99+
@change="onFileChange"
100+
>
101+
<form
102+
ref="formRef"
103+
:class="cn('w-full', props.class)"
104+
@submit="onSubmit"
105+
@dragover.prevent="handleDragOver"
106+
@drop.prevent="handleDrop"
107+
>
108+
<InputGroup class="overflow-hidden">
109+
<slot />
110+
</InputGroup>
111+
</form>
112+
</div>
21113
</template>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
import { DropdownMenuItem } from '@repo/shadcn-vue/components/ui/dropdown-menu'
3+
import { ImageIcon } from 'lucide-vue-next'
4+
import { usePromptInput } from './context'
5+
6+
type PromptInputActionAddAttachmentsProps = InstanceType<typeof DropdownMenuItem>['$props']
7+
8+
interface Props extends /* @vue-ignore */ PromptInputActionAddAttachmentsProps {
9+
label?: string
10+
}
11+
12+
const props = defineProps<Props>()
13+
14+
const { openFileDialog } = usePromptInput()
15+
</script>
16+
17+
<template>
18+
<DropdownMenuItem @select.prevent="openFileDialog">
19+
<ImageIcon class="mr-2 size-4" />
20+
{{ props.label || 'Add photos or files' }}
21+
</DropdownMenuItem>
22+
</template>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script setup lang="ts">
2+
import { DropdownMenu } from '@repo/shadcn-vue/components/ui/dropdown-menu'
3+
4+
type DropdownMenuProps = InstanceType<typeof DropdownMenu>['$props']
5+
6+
interface Props extends /* @vue-ignore */ DropdownMenuProps {}
7+
8+
const props = defineProps<Props>()
9+
</script>
10+
11+
<template>
12+
<DropdownMenu v-bind="props">
13+
<slot />
14+
</DropdownMenu>
15+
</template>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { DropdownMenuContent } from '@repo/shadcn-vue/components/ui/dropdown-menu'
4+
import { cn } from '@repo/shadcn-vue/lib/utils'
5+
6+
type DropdownMenuContentProps = InstanceType<typeof DropdownMenuContent>['$props']
7+
8+
interface Props extends /* @vue-ignore */ DropdownMenuContentProps {
9+
class?: HTMLAttributes['class']
10+
}
11+
12+
const props = defineProps<Props>()
13+
14+
const { align, class: _, ...restProps } = props
15+
</script>
16+
17+
<template>
18+
<DropdownMenuContent align="start" :class="cn(props.class)" v-bind="restProps">
19+
<slot />
20+
</DropdownMenuContent>
21+
</template>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { DropdownMenuItem } from '@repo/shadcn-vue/components/ui/dropdown-menu'
4+
import { cn } from '@repo/shadcn-vue/lib/utils'
5+
6+
type PromptInputActionMenuItemProps = InstanceType<typeof DropdownMenuItem>['$props']
7+
8+
interface Props extends /* @vue-ignore */ PromptInputActionMenuItemProps {
9+
class?: HTMLAttributes['class']
10+
}
11+
12+
const props = defineProps<Props>()
13+
</script>
14+
15+
<template>
16+
<DropdownMenuItem :class="cn(props.class)">
17+
<slot />
18+
</DropdownMenuItem>
19+
</template>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { DropdownMenuTrigger } from '@repo/shadcn-vue/components/ui/dropdown-menu'
4+
import { PlusIcon } from 'lucide-vue-next'
5+
import PromptInputButton from './PromptInputButton.vue'
6+
7+
type DropdownMenuTriggerProps = InstanceType<typeof DropdownMenuTrigger>['$props']
8+
9+
interface Props extends /* @vue-ignore */ DropdownMenuTriggerProps {
10+
class?: HTMLAttributes['class']
11+
}
12+
13+
const props = defineProps<Props>()
14+
</script>
15+
16+
<template>
17+
<DropdownMenuTrigger as-child>
18+
<PromptInputButton :class="props.class" v-bind="props">
19+
<slot><PlusIcon class="size-4" /></slot>
20+
</PromptInputButton>
21+
</DropdownMenuTrigger>
22+
</template>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<script setup lang="ts">
2+
import type { AttachmentFile } from './types'
3+
import { Button } from '@repo/shadcn-vue/components/ui/button'
4+
import {
5+
HoverCard,
6+
HoverCardContent,
7+
HoverCardTrigger,
8+
} from '@repo/shadcn-vue/components/ui/hover-card'
9+
import { cn } from '@repo/shadcn-vue/lib/utils'
10+
import { PaperclipIcon, XIcon } from 'lucide-vue-next'
11+
import { computed } from 'vue'
12+
import { usePromptInput } from './context'
13+
14+
const props = defineProps<{
15+
file: AttachmentFile
16+
class?: string
17+
}>()
18+
19+
const { removeFile } = usePromptInput()
20+
21+
const filename = computed(() => props.file.filename || '')
22+
const isImage = computed(() =>
23+
props.file.mediaType?.startsWith('image/') && props.file.url,
24+
)
25+
const label = computed(() => filename.value || (isImage.value ? 'Image' : 'Attachment'))
26+
27+
function handleRemove(e: Event) {
28+
e.stopPropagation()
29+
removeFile(props.file.id)
30+
}
31+
</script>
32+
33+
<template>
34+
<HoverCard :open-delay="0" :close-delay="0">
35+
<HoverCardTrigger as-child>
36+
<div
37+
:class="cn(
38+
'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',
39+
props.class,
40+
)"
41+
>
42+
<div class="relative size-5 shrink-0">
43+
<div class="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
44+
<img
45+
v-if="isImage"
46+
:src="file.url"
47+
:alt="label"
48+
class="size-5 object-cover"
49+
>
50+
<div v-else class="flex size-5 items-center justify-center text-muted-foreground">
51+
<PaperclipIcon class="size-3" />
52+
</div>
53+
</div>
54+
55+
<Button
56+
type="button"
57+
variant="ghost"
58+
size="icon"
59+
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"
60+
@click="handleRemove"
61+
>
62+
<XIcon />
63+
<span class="sr-only">Remove</span>
64+
</Button>
65+
</div>
66+
67+
<span class="flex-1 truncate max-w-[150px]">{{ label }}</span>
68+
</div>
69+
</HoverCardTrigger>
70+
71+
<HoverCardContent class="w-auto p-2" align="start">
72+
<div class="w-auto space-y-3">
73+
<div v-if="isImage" class="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
74+
<img
75+
:src="file.url"
76+
:alt="label"
77+
class="max-h-full max-w-full object-contain"
78+
>
79+
</div>
80+
<div class="flex items-center gap-2.5">
81+
<div class="min-w-0 flex-1 space-y-1 px-0.5">
82+
<h4 class="truncate font-semibold text-sm leading-none">
83+
{{ label }}
84+
</h4>
85+
<p v-if="file.mediaType" class="truncate font-mono text-muted-foreground text-xs">
86+
{{ file.mediaType }}
87+
</p>
88+
</div>
89+
</div>
90+
</div>
91+
</HoverCardContent>
92+
</HoverCard>
93+
</template>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { cn } from '@repo/shadcn-vue/lib/utils'
4+
import { usePromptInput } from './context'
5+
6+
const props = defineProps<{
7+
class?: HTMLAttributes['class']
8+
}>()
9+
10+
const { files } = usePromptInput()
11+
</script>
12+
13+
<template>
14+
<div
15+
v-if="files.length > 0"
16+
:class="cn('flex flex-wrap items-center gap-2 p-3 w-full', props.class)"
17+
>
18+
<template v-for="file in files" :key="file.id">
19+
<slot :file="file" />
20+
</template>
21+
</div>
22+
</template>

0 commit comments

Comments
 (0)