Skip to content

Commit 8196e26

Browse files
authored
feat: add reasoning component (#40)
1 parent f9fedd4 commit 8196e26

File tree

10 files changed

+728
-1
lines changed

10 files changed

+728
-1
lines changed

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

Lines changed: 443 additions & 0 deletions
Large diffs are not rendered by default.

apps/www/plugins/ai-elements.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
Queue,
2828
QueueCustom,
2929
QueuePromptInput,
30+
Reasoning,
3031
Response,
3132
Shimmer,
3233
ShimmerCustomElements,
@@ -97,5 +98,6 @@ export default defineNuxtPlugin((nuxtApp) => {
9798
vueApp.component('ConfirmationAccepted', ConfirmationAccepted)
9899
vueApp.component('ConfirmationRejected', ConfirmationRejected)
99100
vueApp.component('ConfirmationRequest', ConfirmationRequest)
101+
vueApp.component('Reasoning', Reasoning)
100102
vueApp.component('WebPreview', WebPreview)
101103
})

packages/elements/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ export * from './branch'
44
export * from './chain-of-thought'
55
export * from './checkpoint'
66
export * from './code-block'
7-
export * from './context'
87
export * from './confirmation'
8+
export * from './context'
99
export * from './conversation'
1010
export * from './image'
1111
export * from './inline-citation'
@@ -16,6 +16,7 @@ export * from './open-in-chat'
1616
export * from './plan'
1717
export * from './prompt-input'
1818
export * from './queue'
19+
export * from './reasoning'
1920
export * from './response'
2021
export * from './shimmer'
2122
export * from './sources'
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { Collapsible } from '@repo/shadcn-vue/components/ui/collapsible'
4+
import { cn } from '@repo/shadcn-vue/lib/utils'
5+
import { useVModel } from '@vueuse/core'
6+
import { computed, provide, ref, watch } from 'vue'
7+
import { ReasoningKey } from './context'
8+
9+
interface Props {
10+
class?: HTMLAttributes['class']
11+
isStreaming?: boolean
12+
open?: boolean
13+
defaultOpen?: boolean
14+
duration?: number
15+
}
16+
17+
const props = withDefaults(defineProps<Props>(), {
18+
isStreaming: false,
19+
defaultOpen: true,
20+
duration: undefined,
21+
})
22+
23+
const emit = defineEmits<{
24+
(e: 'update:open', value: boolean): void
25+
(e: 'update:duration', value: number): void
26+
}>()
27+
28+
const isOpen = useVModel(props, 'open', emit, {
29+
defaultValue: props.defaultOpen,
30+
passive: true,
31+
})
32+
33+
const internalDuration = ref<number | undefined>(props.duration)
34+
35+
watch(() => props.duration, (newVal) => {
36+
internalDuration.value = newVal
37+
})
38+
39+
function updateDuration(val: number) {
40+
internalDuration.value = val
41+
emit('update:duration', val)
42+
}
43+
44+
const hasAutoClosed = ref(false)
45+
const startTime = ref<number | null>(null)
46+
47+
const MS_IN_S = 1000
48+
const AUTO_CLOSE_DELAY = 1000
49+
50+
// Track duration when streaming starts and ends
51+
watch(() => props.isStreaming, (streaming) => {
52+
if (streaming) {
53+
// Auto-open when streaming starts
54+
isOpen.value = true
55+
56+
if (startTime.value === null) {
57+
startTime.value = Date.now()
58+
}
59+
}
60+
else if (startTime.value !== null) {
61+
const calculatedDuration = Math.ceil((Date.now() - startTime.value) / MS_IN_S)
62+
updateDuration(calculatedDuration)
63+
startTime.value = null
64+
}
65+
})
66+
67+
// Auto-close logic
68+
watch([() => props.isStreaming, isOpen, () => props.defaultOpen, hasAutoClosed], (_, __, onCleanup) => {
69+
if (props.defaultOpen && !props.isStreaming && isOpen.value && !hasAutoClosed.value) {
70+
const timer = setTimeout(() => {
71+
isOpen.value = false
72+
hasAutoClosed.value = true
73+
}, AUTO_CLOSE_DELAY)
74+
75+
onCleanup(() => clearTimeout(timer))
76+
}
77+
}, { immediate: true })
78+
79+
provide(ReasoningKey, {
80+
isStreaming: computed(() => props.isStreaming),
81+
isOpen,
82+
setIsOpen: (val: boolean) => { isOpen.value = val },
83+
duration: computed(() => internalDuration.value),
84+
})
85+
</script>
86+
87+
<template>
88+
<Collapsible
89+
v-model:open="isOpen"
90+
:class="cn('not-prose mb-4', props.class)"
91+
>
92+
<slot />
93+
</Collapsible>
94+
</template>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { CollapsibleContent } from '@repo/shadcn-vue/components/ui/collapsible'
4+
import { cn } from '@repo/shadcn-vue/lib/utils'
5+
import { StreamMarkdown } from 'streamdown-vue'
6+
import { computed, useSlots } from 'vue'
7+
8+
interface Props {
9+
class?: HTMLAttributes['class']
10+
content: string
11+
}
12+
13+
const props = defineProps<Props>()
14+
const slots = useSlots()
15+
16+
const slotContent = computed<string | undefined>(() => {
17+
const nodes = slots.default?.() || []
18+
let text = ''
19+
for (const node of nodes) {
20+
if (typeof node.children === 'string')
21+
text += node.children
22+
}
23+
return text || undefined
24+
})
25+
26+
const md = computed(() => (slotContent.value ?? props.content ?? '') as string)
27+
</script>
28+
29+
<template>
30+
<CollapsibleContent
31+
:class="cn(
32+
'mt-4 text-sm',
33+
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2',
34+
'data-[state=open]:slide-in-from-top-2 text-muted-foreground',
35+
'outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
36+
props.class,
37+
)"
38+
>
39+
<StreamMarkdown :content="md" />
40+
</CollapsibleContent>
41+
</template>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { CollapsibleTrigger } from '@repo/shadcn-vue/components/ui/collapsible'
4+
import { cn } from '@repo/shadcn-vue/lib/utils'
5+
import { BrainIcon, ChevronDownIcon } from 'lucide-vue-next'
6+
import { computed } from 'vue'
7+
import { Shimmer } from '../shimmer'
8+
import { useReasoningContext } from './context'
9+
10+
interface Props {
11+
class?: HTMLAttributes['class']
12+
}
13+
14+
const props = defineProps<Props>()
15+
16+
const { isStreaming, isOpen, duration } = useReasoningContext()
17+
18+
const thinkingMessage = computed(() => {
19+
if (isStreaming.value || duration.value === 0) {
20+
return 'thinking'
21+
}
22+
if (duration.value === undefined) {
23+
return 'default_done'
24+
}
25+
return 'duration_done'
26+
})
27+
</script>
28+
29+
<template>
30+
<CollapsibleTrigger
31+
:class="cn(
32+
'flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground',
33+
props.class,
34+
)"
35+
>
36+
<slot>
37+
<BrainIcon class="size-4" />
38+
39+
<template v-if="thinkingMessage === 'thinking'">
40+
<Shimmer :duration="1">
41+
Thinking...
42+
</Shimmer>
43+
</template>
44+
45+
<template v-else-if="thinkingMessage === 'default_done'">
46+
<p>Thought for a few seconds</p>
47+
</template>
48+
49+
<template v-else>
50+
<p>Thought for {{ duration }} seconds</p>
51+
</template>
52+
53+
<ChevronDownIcon
54+
:class="cn(
55+
'size-4 transition-transform',
56+
isOpen ? 'rotate-180' : 'rotate-0',
57+
)"
58+
/>
59+
</slot>
60+
</CollapsibleTrigger>
61+
</template>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { InjectionKey, Ref } from 'vue'
2+
import { inject } from 'vue'
3+
4+
export interface ReasoningContextValue {
5+
isStreaming: Ref<boolean>
6+
isOpen: Ref<boolean>
7+
setIsOpen: (open: boolean) => void
8+
duration: Ref<number | undefined>
9+
}
10+
11+
export const ReasoningKey: InjectionKey<ReasoningContextValue>
12+
= Symbol('ReasoningContext')
13+
14+
export function useReasoningContext() {
15+
const ctx = inject<ReasoningContextValue>(ReasoningKey)
16+
if (!ctx)
17+
throw new Error('Reasoning components must be used within <Reasoning>')
18+
return ctx
19+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as Reasoning } from './Reasoning.vue'
2+
export { default as ReasoningContent } from './ReasoningContent.vue'
3+
export { default as ReasoningTrigger } from './ReasoningTrigger.vue'

packages/examples/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export { default as PromptInput } from './prompt-input.vue'
2626
export { default as QueueCustom } from './queue-custom.vue'
2727
export { default as QueuePromptInput } from './queue-prompt-input.vue'
2828
export { default as Queue } from './queue.vue'
29+
export { default as Reasoning } from './reasoning.vue'
2930
export { default as Response } from './response.vue'
3031
export { default as ShimmerCustomElements } from './shimmer-custom-elements.vue'
3132
export { default as ShimmerDurations } from './shimmer-durations.vue'
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script setup lang="ts">
2+
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@repo/elements/reasoning'
3+
import { onMounted, ref } from 'vue'
4+
5+
const reasoningSteps = [
6+
'Let me think about this problem step by step.',
7+
'\n\nFirst, I need to understand what the user is asking for.',
8+
'\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.',
9+
'\n\nThis seems like a collapsible component with state management would be the right approach.',
10+
].join('')
11+
12+
const content = ref('')
13+
const isStreaming = ref(false)
14+
const currentTokenIndex = ref(0)
15+
const tokens = ref<string[]>([])
16+
17+
function chunkIntoTokens(text: string): string[] {
18+
const tokenArray: string[] = []
19+
let i = 0
20+
while (i < text.length) {
21+
const chunkSize = Math.floor(Math.random() * 2) + 3
22+
tokenArray.push(text.slice(i, i + chunkSize))
23+
i += chunkSize
24+
}
25+
return tokenArray
26+
}
27+
28+
function streamToken() {
29+
if (!isStreaming.value || currentTokenIndex.value >= tokens.value.length) {
30+
if (isStreaming.value) {
31+
isStreaming.value = false
32+
}
33+
return
34+
}
35+
36+
content.value += tokens.value[currentTokenIndex.value]
37+
currentTokenIndex.value++
38+
39+
setTimeout(streamToken, 25)
40+
}
41+
42+
function startSimulation() {
43+
tokens.value = chunkIntoTokens(reasoningSteps)
44+
content.value = ''
45+
currentTokenIndex.value = 0
46+
isStreaming.value = true
47+
streamToken()
48+
}
49+
50+
onMounted(() => {
51+
startSimulation()
52+
})
53+
</script>
54+
55+
<template>
56+
<div class="w-full p-4" style="height: 300px">
57+
<Reasoning class="w-full" :is-streaming="isStreaming">
58+
<ReasoningTrigger />
59+
<ReasoningContent :content="content" />
60+
</Reasoning>
61+
</div>
62+
</template>

0 commit comments

Comments
 (0)