工具
一个可折叠组件,用于在 AI 聊天机器人界面中显示工具调用详细信息。
Tool 组件显示一个可折叠界面,用于显示/隐藏工具详细信息。它设计用于接受来自 AI SDK 的 ToolUIPart 类型,并在可折叠界面中显示它。
使用 CLI 安装
AI Elements Vue
shadcn-vue CLI
npx ai-elements-vue@latest add tool
npx shadcn-vue@latest add https://registry.ai-elements-vue.com/tool.json
手动安装
将以下代码复制并粘贴到同一文件夹中。
Tool.vue
ToolStatusBadge.vue
ToolHeader.vue
ToolContent.vue
ToolInput.vue
ToolOutput.vue
index.ts
<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'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Collapsible
:class="cn('not-prose mb-4 w-full rounded-md border', props.class)"
v-bind="$attrs"
>
<slot />
</Collapsible>
</template>
<!-- StatusBadge.vue -->
<script setup lang="ts">
import type { ToolUIPart } from 'ai'
import type { Component } from 'vue'
import { Badge } from '@repo/shadcn-vue/components/ui/badge'
import {
CheckCircleIcon,
CircleIcon,
ClockIcon,
XCircleIcon,
} from 'lucide-vue-next'
import { computed } from 'vue'
const props = defineProps<{
state: ToolUIPart['state']
}>()
const label = computed(() => {
const labels: Record<ToolUIPart['state'], string> = {
'input-streaming': 'Pending',
'input-available': 'Running',
'approval-requested': 'Awaiting Approval',
'approval-responded': 'Responded',
'output-available': 'Completed',
'output-error': 'Error',
'output-denied': 'Denied',
}
return labels[props.state]
})
const icon = computed<Component>(() => {
const icons: Record<ToolUIPart['state'], Component> = {
'input-streaming': CircleIcon,
'input-available': ClockIcon,
'approval-requested': ClockIcon,
'approval-responded': CheckCircleIcon,
'output-available': CheckCircleIcon,
'output-error': XCircleIcon,
'output-denied': XCircleIcon,
}
return icons[props.state]
})
const iconClass = computed(() => {
const classes: Record<string, boolean> = {
'size-4': true,
'animate-pulse': props.state === 'input-available',
'text-yellow-600': props.state === 'approval-requested',
'text-blue-600': props.state === 'approval-responded',
'text-green-600': props.state === 'output-available',
'text-red-600': props.state === 'output-error',
'text-orange-600': props.state === 'output-denied',
}
return classes
})
</script>
<template>
<Badge class="gap-1.5 rounded-full text-xs" variant="secondary">
<component :is="icon" :class="iconClass" />
<span>{{ label }}</span>
</Badge>
</template>
<script setup lang="ts">
import type { ToolUIPart } from 'ai'
import type { HTMLAttributes } from 'vue'
import { CollapsibleTrigger } from '@repo/shadcn-vue/components/ui/collapsible'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { ChevronDownIcon, WrenchIcon } from 'lucide-vue-next'
import StatusBadge from './ToolStatusBadge.vue'
const props = defineProps<{
title?: string
type: ToolUIPart['type']
state: ToolUIPart['state']
class?: HTMLAttributes['class']
}>()
</script>
<template>
<CollapsibleTrigger
:class="
cn(
'flex w-full items-center justify-between gap-4 p-3',
props.class,
)
"
v-bind="$attrs"
>
<div class="flex items-center gap-2">
<WrenchIcon class="size-4 text-muted-foreground" />
<span class="font-medium text-sm">
{{ props.title ?? props.type.split('-').slice(1).join(' ') }}
</span>
<StatusBadge :state="props.state" />
</div>
<ChevronDownIcon
class="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180"
/>
</CollapsibleTrigger>
</template>
<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'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<CollapsibleContent
:class="
cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
props.class,
)
"
v-bind="$attrs"
>
<slot />
</CollapsibleContent>
</template>
<script setup lang="ts">
import type { ToolUIPart } from 'ai'
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { computed } from 'vue'
import { CodeBlock } from '../code-block'
const props = defineProps<{
input: ToolUIPart['input']
class?: HTMLAttributes['class']
}>()
const formattedInput = computed(() => {
return JSON.stringify(props.input, null, 2)
})
</script>
<template>
<div
:class="cn('space-y-2 overflow-hidden p-4', props.class)"
v-bind="$attrs"
>
<h4
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Parameters
</h4>
<div class="rounded-md bg-muted/50">
<CodeBlock :code="formattedInput" language="json" />
</div>
</div>
</template>
<script setup lang="ts">
import type { ToolUIPart } from 'ai'
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { computed } from 'vue'
import CodeBlock from './CodeBlock.vue'
const props = defineProps<{
output: ToolUIPart['output']
errorText: ToolUIPart['errorText']
class?: HTMLAttributes['class']
}>()
const showOutput = computed(() => props.output || props.errorText)
const isObjectOutput = computed(
() => typeof props.output === 'object' && props.output !== null,
)
const isStringOutput = computed(() => typeof props.output === 'string')
const formattedOutput = computed(() => {
if (isObjectOutput.value) {
return JSON.stringify(props.output, null, 2)
}
return props.output as string
})
</script>
<template>
<div
v-if="showOutput"
:class="cn('space-y-2 p-4', props.class)"
v-bind="$attrs"
>
<h4
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
{{ props.errorText ? "Error" : "Result" }}
</h4>
<div
:class="
cn(
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
props.errorText
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground',
)
"
>
<div v-if="errorText" class="p-3">
{{ props.errorText }}
</div>
<CodeBlock
v-else-if="isObjectOutput"
:code="formattedOutput"
language="json"
/>
<CodeBlock
v-else-if="isStringOutput"
:code="formattedOutput"
language="json"
/>
<div v-else class="p-3">
{{ props.output }}
</div>
</div>
</div>
</template>
export { default as Tool } from './Tool.vue'
export { default as ToolContent } from './ToolContent.vue'
export { default as ToolHeader } from './ToolHeader.vue'
export { default as ToolInput } from './ToolInput.vue'
export { default as ToolOutput } from './ToolOutput.vue'
与 AI SDK 一起使用
构建一个简单的有状态天气应用,使用 useChat 在工具中渲染最后一条消息。
将以下组件添加到你的前端:
pages/index.vue
<script setup lang="ts">
import type { ToolUIPart } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { DefaultChatTransport } from 'ai'
import { computed, h } from 'vue'
import { MessageResponse } from '@/components/ai-elements/message'
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool'
import { Button } from '@/components/ui/button'
interface WeatherToolInput {
location: string
units: 'celsius' | 'fahrenheit'
}
interface WeatherToolOutput {
location: string
temperature: string
conditions: string
humidity: string
windSpeed: string
lastUpdated: string
}
type WeatherToolUIPart = ToolUIPart<{
fetch_weather_data: {
input: WeatherToolInput
output: WeatherToolOutput
}
}>
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: '/api/weather',
}),
})
function formatWeatherResult(result: WeatherToolOutput): string {
if (!result)
return ''
return `**Weather for ${result.location}**
**Temperature:** ${result.temperature}
**Conditions:** ${result.conditions}
**Humidity:** ${result.humidity}
**Wind Speed:** ${result.windSpeed}
*Last updated: ${result.lastUpdated}*`
}
function handleWeatherClick() {
sendMessage({ text: 'Get weather data for San Francisco in fahrenheit' })
}
const latestMessage = computed(() => {
if (!messages.value || messages.value.length === 0) {
return undefined
}
return messages.value[messages.value.length - 1]
})
const weatherTool = computed(() => {
return latestMessage.value?.parts?.find(
part => part.type === 'tool-fetch_weather_data'
) as WeatherToolUIPart | undefined
})
const weatherOutputVNode = computed(() => {
if (!weatherTool.value?.output) {
return null
}
const markdown = formatWeatherResult(weatherTool.value.output)
return h(MessageResponse, { content: markdown })
})
</script>
<template>
<div
class="max-w-4xl mx-auto p-6 relative size-full rounded-lg border h-[600px]"
>
<div class="flex flex-col h-full">
<div class="space-y-4">
<Button
:disabled="status !== 'ready'"
@click="handleWeatherClick"
>
Get Weather for San Francisco
</Button>
<Tool v-if="weatherTool" :default-open="true">
<ToolHeader
type="tool-fetch_weather_data"
:state="weatherTool.state"
/>
<ToolContent>
<ToolInput :input="weatherTool.input" />
<ToolOutput
:output="weatherOutputVNode"
:error-text="weatherTool.errorText"
/>
</ToolContent>
</Tool>
</div>
</div>
</div>
</template>
将以下路由添加到你的后端:
server/api/agent.ts
import { convertToModelMessages, streamText, UIMessage } from 'ai'
import { defineEventHandler, readBody } from 'h3'
import { z } from 'zod'
// Allow streaming responses up to 30 seconds
export const maxDuration = 30
export default defineEventHandler(async (event) => {
const body = await readBody(event) as { messages: UIMessage[] }
const { messages } = body
const result = streamText({
model: 'openai/gpt-4o',
messages: convertToModelMessages(messages),
tools: {
fetch_weather_data: {
description: 'Fetch weather information for a specific location',
parameters: z.object({
location: z.string().describe('The city or location to get weather for'),
units: z.enum(['celsius', 'fahrenheit']).default('celsius').describe('Temperature units'),
}),
inputSchema: z.object({
location: z.string(),
units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
}),
execute: async ({ location, units }) => {
await new Promise(resolve => setTimeout(resolve, 1500))
const temp = units === 'celsius'
? Math.floor(Math.random() * 35) + 5
: Math.floor(Math.random() * 63) + 41
return {
location,
temperature: `${temp}°${units === 'celsius' ? 'C' : 'F'}`,
conditions: 'Sunny',
humidity: '12%',
windSpeed: `35 ${units === 'celsius' ? 'km/h' : 'mph'}`,
lastUpdated: new Date().toLocaleString(),
}
},
},
},
})
return result.toUIMessageStreamResponse()
})
特性
- 可折叠界面,用于显示/隐藏工具详细信息
- 带有图标和徽章的可视化状态指示器
- 支持多种工具执行状态(待处理、运行中、已完成、错误)
- 格式化的参数显示,带有 JSON 语法高亮
- 结果和错误处理,具有适当的样式
- 可组合结构,用于灵活的布局
- 可访问的键盘导航和屏幕阅读器支持
- 与你的设计系统匹配的一致样式
- 默认自动打开已完成的工具以获得更好的用户体验
示例
输入流式传输(待处理)
显示处于初始状态的工具,同时正在处理参数。
输入可用(运行中)
显示正在使用其参数主动执行的工具。
输入流式传输(已完成)
显示已完成且结果成功的工具。默认打开以显示结果。在这种情况下,输出是一个 JSON 对象,因此我们可以使用 CodeBlock 组件来显示它。
输出错误
显示在执行过程中遇到错误的工具。默认打开以显示错误。
Props
<Tool/>
classstring
<ToolHeader/>
typeToolUIPart['type']
stateToolUIPart['state']
titlestring
classstring
<ToolContent/>
classstring
<ToolInput/>
inputToolUIPart['input']
classstring
<ToolOutput/>
outputToolUIPart['output']
errorTextToolUIPart['errorText']
classstring