Skip to content
Open
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
443 changes: 443 additions & 0 deletions apps/www/content/3.components/1.chatbot/reasoning.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion apps/www/plugins/ai-elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import {
Checkpoint,
CodeBlock,
CodeBlockDark,
Context,
Confirmation,
ConfirmationAccepted,
ConfirmationRejected,
ConfirmationRequest,
Context,
Conversation,
Image,
InlineCitation,
Expand All @@ -27,6 +27,7 @@ import {
Queue,
QueueCustom,
QueuePromptInput,
Reasoning,
Response,
Shimmer,
ShimmerCustomElements,
Expand Down Expand Up @@ -96,4 +97,5 @@ export default defineNuxtPlugin((nuxtApp) => {
vueApp.component('ConfirmationAccepted', ConfirmationAccepted)
vueApp.component('ConfirmationRejected', ConfirmationRejected)
vueApp.component('ConfirmationRequest', ConfirmationRequest)
vueApp.component('Reasoning', Reasoning)
})
3 changes: 2 additions & 1 deletion packages/elements/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ export * from './branch'
export * from './chain-of-thought'
export * from './checkpoint'
export * from './code-block'
export * from './context'
export * from './confirmation'
export * from './context'
export * from './conversation'
export * from './image'
export * from './inline-citation'
Expand All @@ -16,6 +16,7 @@ export * from './open-in-chat'
export * from './plan'
export * from './prompt-input'
export * from './queue'
export * from './reasoning'
export * from './response'
export * from './shimmer'
export * from './sources'
Expand Down
94 changes: 94 additions & 0 deletions packages/elements/src/reasoning/Reasoning.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Collapsible } from '@repo/shadcn-vue/components/ui/collapsible'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { useVModel } from '@vueuse/core'
import { computed, provide, ref, watch } from 'vue'
import { ReasoningKey } from './context'

interface Props {
class?: HTMLAttributes['class']
isStreaming?: boolean
open?: boolean
defaultOpen?: boolean
duration?: number
}

const props = withDefaults(defineProps<Props>(), {
isStreaming: false,
defaultOpen: true,
duration: undefined,
})

const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'update:duration', value: number): void
}>()

const isOpen = useVModel(props, 'open', emit, {
defaultValue: props.defaultOpen,
passive: true,
})

const internalDuration = ref<number | undefined>(props.duration)

watch(() => props.duration, (newVal) => {
internalDuration.value = newVal
})

function updateDuration(val: number) {
internalDuration.value = val
emit('update:duration', val)
}

const hasAutoClosed = ref(false)
const startTime = ref<number | null>(null)

const MS_IN_S = 1000
const AUTO_CLOSE_DELAY = 1000

// Track duration when streaming starts and ends
watch(() => props.isStreaming, (streaming) => {
if (streaming) {
// Auto-open when streaming starts
isOpen.value = true

if (startTime.value === null) {
startTime.value = Date.now()
}
}
else if (startTime.value !== null) {
const calculatedDuration = Math.ceil((Date.now() - startTime.value) / MS_IN_S)
updateDuration(calculatedDuration)
startTime.value = null
}
})

// Auto-close logic
watch([() => props.isStreaming, isOpen, () => props.defaultOpen, hasAutoClosed], (_, __, onCleanup) => {
if (props.defaultOpen && !props.isStreaming && isOpen.value && !hasAutoClosed.value) {
const timer = setTimeout(() => {
isOpen.value = false
hasAutoClosed.value = true
}, AUTO_CLOSE_DELAY)

onCleanup(() => clearTimeout(timer))
}
}, { immediate: true })

provide(ReasoningKey, {
isStreaming: computed(() => props.isStreaming),
isOpen,
setIsOpen: (val: boolean) => { isOpen.value = val },
duration: computed(() => internalDuration.value),
})
</script>

<template>
<Collapsible
v-model:open="isOpen"
:class="cn('not-prose mb-4', props.class)"
>
<slot />
</Collapsible>
</template>
41 changes: 41 additions & 0 deletions packages/elements/src/reasoning/ReasoningContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CollapsibleContent } from '@repo/shadcn-vue/components/ui/collapsible'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { StreamMarkdown } from 'streamdown-vue'
import { computed, useSlots } from 'vue'

interface Props {
class?: HTMLAttributes['class']
content: string
}

const props = defineProps<Props>()
const slots = useSlots()

const slotContent = computed<string | undefined>(() => {
const nodes = slots.default?.() || []
let text = ''
for (const node of nodes) {
if (typeof node.children === 'string')
text += node.children
}
return text || undefined
})

const md = computed(() => (slotContent.value ?? props.content ?? '') as string)
</script>

<template>
<CollapsibleContent
:class="cn(
'mt-4 text-sm',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2',
'data-[state=open]:slide-in-from-top-2 text-muted-foreground',
'outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
props.class,
)"
>
<StreamMarkdown :content="md" />
</CollapsibleContent>
</template>
61 changes: 61 additions & 0 deletions packages/elements/src/reasoning/ReasoningTrigger.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { CollapsibleTrigger } from '@repo/shadcn-vue/components/ui/collapsible'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { BrainIcon, ChevronDownIcon } from 'lucide-vue-next'
import { computed } from 'vue'
import { Shimmer } from '../shimmer'
import { useReasoningContext } from './context'

interface Props {
class?: HTMLAttributes['class']
}

const props = defineProps<Props>()

const { isStreaming, isOpen, duration } = useReasoningContext()

const thinkingMessage = computed(() => {
if (isStreaming.value || duration.value === 0) {
return 'thinking'
}
if (duration.value === undefined) {
return 'default_done'
}
return 'duration_done'
})
</script>

<template>
<CollapsibleTrigger
:class="cn(
'flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground',
props.class,
)"
>
<slot>
<BrainIcon class="size-4" />

<template v-if="thinkingMessage === 'thinking'">
<Shimmer :duration="1">
Thinking...
</Shimmer>
</template>

<template v-else-if="thinkingMessage === 'default_done'">
<p>Thought for a few seconds</p>
</template>

<template v-else>
<p>Thought for {{ duration }} seconds</p>
</template>

<ChevronDownIcon
:class="cn(
'size-4 transition-transform',
isOpen ? 'rotate-180' : 'rotate-0',
)"
/>
</slot>
</CollapsibleTrigger>
</template>
19 changes: 19 additions & 0 deletions packages/elements/src/reasoning/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { InjectionKey, Ref } from 'vue'
import { inject } from 'vue'

export interface ReasoningContextValue {
isStreaming: Ref<boolean>
isOpen: Ref<boolean>
setIsOpen: (open: boolean) => void
duration: Ref<number | undefined>
}

export const ReasoningKey: InjectionKey<ReasoningContextValue>
= Symbol('ReasoningContext')

export function useReasoningContext() {
const ctx = inject<ReasoningContextValue>(ReasoningKey)
if (!ctx)
throw new Error('Reasoning components must be used within <Reasoning>')
return ctx
}
3 changes: 3 additions & 0 deletions packages/elements/src/reasoning/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as Reasoning } from './Reasoning.vue'
export { default as ReasoningContent } from './ReasoningContent.vue'
export { default as ReasoningTrigger } from './ReasoningTrigger.vue'
3 changes: 2 additions & 1 deletion packages/examples/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ export { default as ChainOfThought } from './chain-of-thought.vue'
export { default as Checkpoint } from './checkpoint.vue'
export { default as CodeBlockDark } from './code-block-dark.vue'
export { default as CodeBlock } from './code-block.vue'
export { default as Context } from './context.vue'
export { default as ConfirmationAccepted } from './confirmation-accepted.vue'
export { default as ConfirmationRejected } from './confirmation-rejected.vue'
export { default as ConfirmationRequest } from './confirmation-request.vue'
export { default as Confirmation } from './confirmation.vue'
export { default as Context } from './context.vue'
export { default as Conversation } from './conversation.vue'
export { default as Image } from './image.vue'
export { default as InlineCitation } from './inline-citation.vue'
Expand All @@ -26,6 +26,7 @@ export { default as PromptInput } from './prompt-input.vue'
export { default as QueueCustom } from './queue-custom.vue'
export { default as QueuePromptInput } from './queue-prompt-input.vue'
export { default as Queue } from './queue.vue'
export { default as Reasoning } from './reasoning.vue'
export { default as Response } from './response.vue'
export { default as ShimmerCustomElements } from './shimmer-custom-elements.vue'
export { default as ShimmerDurations } from './shimmer-durations.vue'
Expand Down
62 changes: 62 additions & 0 deletions packages/examples/src/reasoning.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@repo/elements/reasoning'
import { onMounted, ref } from 'vue'

const reasoningSteps = [
'Let me think about this problem step by step.',
'\n\nFirst, I need to understand what the user is asking for.',
'\n\nThey want a reasoning component that opens automatically when streaming begins and closes when streaming finishes. The component should be composable and follow existing patterns in the codebase.',
'\n\nThis seems like a collapsible component with state management would be the right approach.',
].join('')

const content = ref('')
const isStreaming = ref(false)
const currentTokenIndex = ref(0)
const tokens = ref<string[]>([])

function chunkIntoTokens(text: string): string[] {
const tokenArray: string[] = []
let i = 0
while (i < text.length) {
const chunkSize = Math.floor(Math.random() * 2) + 3
tokenArray.push(text.slice(i, i + chunkSize))
i += chunkSize
}
return tokenArray
}

function streamToken() {
if (!isStreaming.value || currentTokenIndex.value >= tokens.value.length) {
if (isStreaming.value) {
isStreaming.value = false
}
return
}

content.value += tokens.value[currentTokenIndex.value]
currentTokenIndex.value++

setTimeout(streamToken, 25)
}

function startSimulation() {
tokens.value = chunkIntoTokens(reasoningSteps)
content.value = ''
currentTokenIndex.value = 0
isStreaming.value = true
streamToken()
}

onMounted(() => {
startSimulation()
})
</script>

<template>
<div class="w-full p-4" style="height: 300px">
<Reasoning class="w-full" :is-streaming="isStreaming">
<ReasoningTrigger />
<ReasoningContent :content="content" />
</Reasoning>
</div>
</template>