Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
17 changes: 3 additions & 14 deletions docs/app/components/content/AssistantDemo.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Chat } from '@ai-sdk/vue'
import { DefaultChatTransport, isToolUIPart, isTextUIPart, getToolName } from 'ai'
import { DefaultChatTransport, isToolUIPart, isTextUIPart } from 'ai'
import { isToolStreaming } from '@nuxt/ui/utils/ai'

const config = useRuntimeConfig()
Expand Down Expand Up @@ -36,18 +36,6 @@ const chat = isEnabled.value
})
: null

function getToolText(part: Parameters<typeof getToolName>[0]) {
const toolName = getToolName(part)
const input = (part as { input?: Record<string, string> }).input
const verb = part.state === 'output-available' ? 'Searched' : 'Searching'
const readVerb = part.state === 'output-available' ? 'Read' : 'Reading'

return {
'list-pages': `${verb} pages`,
'get-page': `${readVerb} ${input?.path || '...'}`,
}[toolName] || `${verb} ${toolName}`
}

function handleSubmit() {
if (!input.value.trim() || !chat) return

Expand Down Expand Up @@ -129,7 +117,8 @@ function resetChat() {
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolText(part)"
:icon="getToolName(part) === 'get-page' ? 'i-lucide-file-text' : 'i-lucide-search'"
:suffix="getToolSuffix(part)"
:icon="getToolIcon(part)"
:streaming="isToolStreaming(part)"
/>
</template>
Expand Down
87 changes: 87 additions & 0 deletions layer/app/utils/assistantTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { getToolName } from 'ai'
import type { ToolUIPart, DynamicToolUIPart } from 'ai'

export type AssistantToolPart = ToolUIPart | DynamicToolUIPart

const VERBS: Record<string, [active: string, done: string]> = {
list: ['Searching', 'Searched'],
search: ['Searching', 'Searched'],
find: ['Searching', 'Searched'],
query: ['Searching', 'Searched'],
get: ['Reading', 'Read'],
read: ['Reading', 'Read'],
fetch: ['Reading', 'Read'],
show: ['Finding', 'Found'],
open: ['Opening', 'Opened'],
generate: ['Generating', 'Generated'],
create: ['Creating', 'Created'],
}

const SUFFIX_KEYS = [
'search',
'query',
'q',
'term',
'path',
'slug',
'templateName',
'componentName',
'composableName',
'name',
'repo',
'section',
'id',
]

function parseToolName(toolName: string): { verb?: [string, string], label: string } {
const parts = toolName.split(/[-_\s]+/).filter(Boolean)
const head = parts[0]?.toLowerCase()

if (head && VERBS[head] && parts.length > 1) {
return { verb: VERBS[head], label: parts.slice(1).join(' ') }
}

return { label: parts.join(' ') || toolName }
}

export function getToolText(part: AssistantToolPart): string {
const done = part.state === 'output-available'
const { verb, label } = parseToolName(getToolName(part))

if (verb) return `${done ? verb[1] : verb[0]} ${label}`

return `${done ? 'Searched' : 'Searching'} ${label}`
}

export function getToolSuffix(part: AssistantToolPart): string | undefined {
const input = (part.input || {}) as Record<string, unknown>

for (const key of SUFFIX_KEYS) {
const value = input[key]
if (typeof value === 'string' && value.trim()) return value
}

return undefined
}

export function getToolIcon(part: AssistantToolPart): string {
const name = getToolName(part).toLowerCase()

if (/icon/.test(name)) return 'i-lucide-smile'
if (/component/.test(name)) return 'i-lucide-box'
if (/composable/.test(name)) return 'i-lucide-square-function'
if (/template/.test(name)) return 'i-lucide-layout-template'
if (/example|snippet/.test(name)) return 'i-lucide-code'
if (/module|package/.test(name)) return 'i-lucide-box'
if (/blog|post|news/.test(name)) return 'i-lucide-newspaper'
if (/changelog|release/.test(name)) return 'i-lucide-history'
if (/deploy|hosting|provider/.test(name)) return 'i-lucide-cloud'
if (/getting.?started|guide/.test(name)) return 'i-lucide-rocket'
if (/issue|github/.test(name)) return 'i-simple-icons-github'
if (/page|doc/.test(name)) {
const isList = /^(?:list|search|find|query)[-_]/.test(name)
return isList ? 'i-lucide-book-open' : 'i-lucide-file-text'
}

return 'i-lucide-search'
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ onUnmounted(() => {
</script>

<template>
<div class="flex items-center text-xs text-muted overflow-hidden">
<div class="flex items-center text-sm text-muted overflow-hidden">
<div
class="shrink-0 mr-2 grid"
:style="{
Expand All @@ -111,6 +111,6 @@ onUnmounted(() => {
/>
</div>

<UChatShimmer :text="displayedText" class="font-mono tracking-tight" />
<UChatShimmer :text="displayedText" />
</div>
</template>
53 changes: 12 additions & 41 deletions layer/modules/assistant/runtime/components/AssistantPanel.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ToolUIPart, DynamicToolUIPart } from 'ai'
import { DefaultChatTransport, isToolUIPart, isReasoningUIPart, isTextUIPart, getToolName } from 'ai'
import { DefaultChatTransport, isToolUIPart, isReasoningUIPart, isTextUIPart } from 'ai'
import { Chat } from '@ai-sdk/vue'
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
import { getToolText, getToolSuffix, getToolIcon } from '../../../../app/utils/assistantTools'
import AssistantComark from './AssistantComark'
import AssistantIndicator from './AssistantIndicator.vue'

Expand Down Expand Up @@ -71,54 +72,22 @@ watch(messages, (newMessages) => {
const canClear = computed(() => messages.value.length > 0)

type ToolPart = ToolUIPart | DynamicToolUIPart
type ToolState = ToolPart['state']

function getToolMessage(state: ToolState, toolName: string, input: Record<string, string | undefined>) {
const searchVerb = state === 'output-available' ? 'Searched' : 'Searching'
const readVerb = state === 'output-available' ? 'Read' : 'Reading'

return {
'list-pages': `${searchVerb} pages`,
'get-page': `${readVerb} ${input.path || '...'}`,
}[toolName] || `${searchVerb} ${toolName}`
}

function getToolText(part: ToolPart) {
return getToolMessage(part.state, getToolName(part), (part.input || {}) as Record<string, string | undefined>)
}

function getToolIcon(part: ToolPart): string {
const toolName = getToolName(part)

return {
'get-page': 'i-lucide-file-text',
}[toolName] || 'i-lucide-search'
}

function getToolOutput(part: ToolPart): string | undefined {
if (part.state !== 'output-available' || !part.output) return undefined

const output = part.output as Record<string, unknown>
const content = (output.content ?? output) as Array<{ text?: string }> | string

if (getToolName(part) === 'list-pages') {
const content = (output.content ?? output) as Array<{ text?: string }> | string
if (typeof content === 'string') return content
return content
?.map(c => c.text)
.filter(Boolean)
.join('\n') || undefined
if (typeof content === 'string') {
return content || undefined
}

if (getToolName(part) === 'get-page') {
const content = (output.content ?? output) as Array<{ text?: string }> | string
if (typeof content === 'string') {
return content.length > 500 ? `${content.slice(0, 500)}…` : content
}
const text = content?.map(c => c.text).filter(Boolean).join('\n') || ''
return text.length > 500 ? `${text.slice(0, 500)}…` : text || undefined
if (Array.isArray(content)) {
return content.map(c => c.text).filter(Boolean).join('\n') || undefined
}

return JSON.stringify(output, null, 2).slice(0, 500)
return JSON.stringify(output, null, 2)
}

function onSubmit() {
Expand Down Expand Up @@ -214,7 +183,7 @@ defineShortcuts({
:status="chat.status"
compact
class="px-0 gap-2"
:user="{ ui: { container: 'max-w-full' } }"
:user="{ ui: { container: 'max-w-full pb-3' } }"
>
<template #indicator>
<AssistantIndicator />
Expand All @@ -230,6 +199,7 @@ defineShortcuts({
:text="part.text"
:streaming="isPartStreaming(part)"
icon="i-lucide-brain"
chevron="leading"
>
<AssistantComark
:markdown="part.text"
Expand All @@ -254,13 +224,14 @@ defineShortcuts({
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolText(part)"
:suffix="getToolSuffix(part)"
:icon="getToolIcon(part)"
:streaming="isToolStreaming(part)"
chevron="leading"
>
<pre
v-if="getToolOutput(part)"
class="text-xs text-dimmed whitespace-pre-wrap"
class="text-xs text-muted whitespace-pre-wrap break-all rounded-md border border-muted bg-muted p-2 max-h-64 overflow-y-auto"
v-text="getToolOutput(part)"
/>
</UChatTool>
Expand Down
30 changes: 14 additions & 16 deletions layer/modules/assistant/runtime/server/api/search.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { streamText, convertToModelMessages } from 'ai'
import { streamText, convertToModelMessages, stepCountIs, smoothStream } from 'ai'
import type { ToolSet } from 'ai'
import { createMCPClient } from '@ai-sdk/mcp'
import type { H3Event } from 'h3'
Expand All @@ -22,19 +22,6 @@ function createLocalFetch(event: H3Event): typeof fetch {
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function stopWhenResponseComplete({ steps }: { steps: { text?: string, toolCalls?: unknown[] }[] }): boolean {
const lastStep = steps.at(-1)
if (!lastStep) return false

const hasText = Boolean(lastStep.text && lastStep.text.trim().length > 0)
const hasNoToolCalls = !lastStep.toolCalls || lastStep.toolCalls.length === 0

if (hasText && hasNoToolCalls) return true

return steps.length >= MAX_STEPS
}

function getSystemPrompt(siteName: string) {
return `You are the documentation assistant for ${siteName}. Help users navigate and understand the project documentation.

Expand Down Expand Up @@ -115,13 +102,24 @@ export default defineEventHandler(async (event) => {

return streamText({
model: config.assistant.model,
maxOutputTokens: 4000,
maxOutputTokens: 8000,
maxRetries: 2,
abortSignal: abortController.signal,
stopWhen: stopWhenResponseComplete,
stopWhen: stepCountIs(MAX_STEPS),
// On the last allowed step, disable tools so the model is forced to
// produce a final text answer instead of stopping mid tool-calling.
prepareStep: ({ stepNumber }) => {
return stepNumber >= MAX_STEPS - 1 ? { toolChoice: 'none' } : {}
},
providerOptions: {
gateway: {
caching: 'auto',
},
},
system: getSystemPrompt(siteName),
messages: await convertToModelMessages(messages),
tools: mcpTools as ToolSet,
experimental_transform: smoothStream(),
onFinish: closeMcp,
onAbort: closeMcp,
onError: closeMcp,
Expand Down
Loading