feat(component-calling): or component use, init

This commit is contained in:
Neko Ayaka
2025-07-29 02:37:29 +08:00
parent cf78423acc
commit 7f178b130f
28 changed files with 1175 additions and 9 deletions
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'
import { RouterLink, RouterView } from 'vue-router'
const isDark = useDark()
const toggleDark = useToggle(isDark)
</script>
<template>
<div mx-auto h-full max-w-screen-lg flex flex-col gap-2 p-4>
<header flex flex-row items-center justify-between>
<h1 text-2xl>
Component Calling
</h1>
<div flex flex-row items-center gap-2>
<button text-lg @click="() => toggleDark()">
<div v-if="isDark" i-solar:moon-stars-bold-duotone />
<div v-else i-solar:sun-bold />
</button>
<a href="https://github.com/moeru-ai/airi/tree/main/apps/component-calling">
<div i-simple-icons:github />
</a>
</div>
</header>
<nav bg="neutral-100 dark:neutral-800" w-fit flex items-center of-hidden rounded-lg>
<RouterLink
to="/" px-3 py-2 bg="hover:neutral-200 dark:hover:neutral-700"
transition="all duration-250 ease-in-out"
>
<h1>Chat</h1>
</RouterLink>
</nav>
<RouterView />
</div>
</template>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
html {
background: #fff;
transition: all 0.3s ease-in-out;
}
html.dark {
background: #121212;
color-scheme: dark;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import 'uno.css'
const router = createRouter({ routes, history: createWebHashHistory() })
createApp(App)
.use(router)
.mount('#app')
+480
View File
@@ -0,0 +1,480 @@
<script setup lang="ts">
import type { AssistantMessage, SystemMessage, ToolMessage, Message as UpstreamMessage, UserMessage } from '@xsai/shared-chat'
import type { Element, Root } from 'xast'
import { Input, Textarea } from '@proj-airi/ui'
import { useLocalStorage } from '@vueuse/core'
import { streamText } from '@xsai/stream-text'
import { ref, toRaw } from 'vue'
import { registerWidgets } from '../plugins/plugin-component-calling-weather'
// import { mockStreamText } from '../utils/xsai-testing'
interface ComponentCall {
component: {
name: string
props: Record<string, any>
propsLoading: boolean
}
index: number
type: 'component'
}
interface AssistantCCMessage extends AssistantMessage {
component_calls?: ComponentCall[]
}
interface UserHidableMessage extends UserMessage {
hidden?: boolean
}
interface ErrorMessage {
role: 'error'
content: string
}
type Message = SystemMessage | ToolMessage | UserHidableMessage | AssistantCCMessage | ErrorMessage
const baseUrl = useLocalStorage('settings/llm/baseUrl', 'https://openrouter.ai/api/v1/')
const apiKey = useLocalStorage('settings/llm/apiKey', '')
const model = useLocalStorage('settings/llm/model', 'openai/gpt-4o-mini')
const widgets = registerWidgets()
const sendingMessage = ref('Hi, what is the weather today in Shanghai? We are in debug mode, you can use Shanghai for city, 29 for degree, cloudy for condition.')
// https://github.com/vllm-project/vllm/blob/2cc571199b1446f376ee019fcafda19155fc6b71/examples/tool_chat_template_deepseekv3.jinja
const capabilityComponentCalling = ''
+ `You are interacting with user from a UI that supports **Component Calling**. You may call one or more functions to assist with the user query.\n`
+ `Component Calling is similar to what you have learned Function Calling or tool call, tool use. In function calling, <tools> will be supplied.\n`
+ `In Component Calling, <components>will be supplied</components>, pick the right one to use.\n`
+ `For each function call, you should return object like:\n`
+ `<component_call><component_name>$componentName</component_name>
\`\`\`json
<component_props>$componentProps</component_props>
\`\`\`</component▁call>\n`
const capabilities = [
{ name: 'Component Calling', description: capabilityComponentCalling, inject: async () => `<components>
${(await Promise.all(widgets.components.map(async c => `<component_name>
${c.name}
</component_name>
<component_props>
${JSON.stringify(await c.schema)}
</component_props>`))).join('\n')}
</component>` },
]
const messages = ref<Array<Message>>([
{
role: 'system',
content: `You are a helpful assistant. You will gain powers from the following capabilities:\n\n## Capabilities\n\n${capabilities.map(cap => `### ${cap.name}\n\n${cap.description}`).join('\n')}\n\n`,
},
])
const streamingMessage = ref<AssistantCCMessage>({ role: 'assistant', content: '' })
const waiting = ref(false)
interface ParserEvents {
onComponentDiscovered?: (component: ComponentCall, index: number) => void
onComponentPropsLoaded?: (component: ComponentCall, index: number) => void
}
interface XMLParser {
consume: (char: string) => void
end: () => Root
}
function createParser(events?: ParserEvents): XMLParser {
// Parser state
let readBuffer: string = ''
let parserSeekTag: 'call' | 'props' | 'name' | undefined
let tagOpened = true
let tagBracketOpened = false
let inCodeBlock = false
// Result tree
const parsedNode: Root = {
type: 'root',
children: [] as Element[],
} as Root
let currentNode: Element | Root = parsedNode
const parentStack: (Element | Root)[] = [parsedNode]
// Component data
const componentCalls: ComponentCall[] = []
let currentComponentIndex = -1
// Buffer for tracking sequential characters for code block detection
let sequentialBackticks = 0
function consume(char: string): void {
// Handle code block markers (```)
if (char === '`') {
sequentialBackticks++
if (sequentialBackticks === 3) {
inCodeBlock = !inCodeBlock
sequentialBackticks = 0
return
}
// Wait for more backticks or add to buffer if not part of a code block marker
if (sequentialBackticks < 3) {
return
}
}
else {
// If we had some backticks but not enough for a code block marker,
// add them to the buffer
if (sequentialBackticks > 0) {
readBuffer += '`'.repeat(sequentialBackticks)
sequentialBackticks = 0
}
}
// Handle XML parsing
if (char === '<' && !tagBracketOpened) {
// Starting a new tag
if (readBuffer.trim()) {
// Save any accumulated text content
if (currentNode !== parsedNode) {
(currentNode as Element).children = [
...(currentNode as Element).children || [],
{ type: 'text', value: readBuffer },
]
}
else {
currentNode.children.push({
type: 'text',
value: readBuffer,
} as any)
}
// If we're collecting component props, try to parse as JSON
if (parserSeekTag === 'props' && inCodeBlock) {
try {
const props = JSON.parse(readBuffer.trim())
// Update the component with the loaded props
if (currentComponentIndex >= 0 && currentComponentIndex < componentCalls.length) {
componentCalls[currentComponentIndex].component.props = props
componentCalls[currentComponentIndex].component.propsLoading = false
// Emit event for props loaded
if (events?.onComponentPropsLoaded) {
events.onComponentPropsLoaded(componentCalls[currentComponentIndex], currentComponentIndex)
}
}
}
catch (e) {
console.error('Failed to parse component props:', e)
}
}
else if (parserSeekTag === 'name') {
const componentName = readBuffer.trim()
// Create a new component with loading state
const newComponent: ComponentCall = {
component: {
name: componentName,
props: {},
propsLoading: true,
},
index: componentCalls.length,
type: 'component',
}
// Add to component calls and record the index
componentCalls.push(newComponent)
currentComponentIndex = componentCalls.length - 1
// Emit event for component discovered
if (events?.onComponentDiscovered) {
events.onComponentDiscovered(newComponent, currentComponentIndex)
}
}
}
readBuffer = ''
tagBracketOpened = true
return
}
if (tagBracketOpened && char === '/') {
// This is a closing tag
tagOpened = false
return
}
if (tagBracketOpened && char === '>') {
// End of a tag (opening or closing)
const tagName = readBuffer.trim()
tagBracketOpened = false
if (tagOpened) {
// This was an opening tag
const newElement: Element = {
type: 'element',
name: tagName,
attributes: {},
children: [],
}
currentNode.children.push(newElement)
parentStack.push(currentNode)
currentNode = newElement
if (tagName === 'component_call') {
parserSeekTag = 'call'
}
else if (tagName === 'component_name') {
parserSeekTag = 'name'
}
else if (tagName === 'component_props') {
parserSeekTag = 'props'
}
}
else {
// This was a closing tag
if (tagName === 'component_call') {
parserSeekTag = undefined
currentComponentIndex = -1 // Reset current component index
}
else if (tagName === 'component_name') {
parserSeekTag = 'call'
}
else if (tagName === 'component_props') {
parserSeekTag = 'call'
// If we didn't manage to parse the props (e.g., invalid JSON),
// mark the component as no longer loading
if (currentComponentIndex >= 0
&& currentComponentIndex < componentCalls.length
&& componentCalls[currentComponentIndex].component.propsLoading) {
componentCalls[currentComponentIndex].component.propsLoading = false
// Emit event for props loaded (even though they may be empty/invalid)
if (events?.onComponentPropsLoaded) {
events.onComponentPropsLoaded(componentCalls[currentComponentIndex], currentComponentIndex)
}
}
}
// Pop back to parent
if (parentStack.length > 0) {
currentNode = parentStack.pop()!
}
}
readBuffer = ''
tagOpened = true
return
}
if (tagBracketOpened) {
// Collecting tag name
readBuffer += char
}
else {
// Collecting content
readBuffer += char
}
}
function end(): Root {
// Handle any remaining text
if (readBuffer.trim()) {
currentNode.children.push({
type: 'text',
value: readBuffer,
} as any)
// Try to parse any remaining props
if (parserSeekTag === 'props' && inCodeBlock && currentComponentIndex >= 0) {
try {
const props = JSON.parse(readBuffer.trim())
componentCalls[currentComponentIndex].component.props = props
componentCalls[currentComponentIndex].component.propsLoading = false
// Emit event for props loaded
if (events?.onComponentPropsLoaded) {
events.onComponentPropsLoaded(componentCalls[currentComponentIndex], currentComponentIndex)
}
}
catch (e) {
console.error('Failed to parse component props:', e)
}
}
}
return parsedNode
}
return {
consume,
end,
}
}
async function handleChatSendMessage() {
if (!sendingMessage.value.trim()) {
return
}
streamingMessage.value = { role: 'assistant', content: '' }
messages.value.push({ role: 'user', content: `## Context of capabilities\n\n${(await Promise.all(capabilities.map(async cap => `### ${cap.name}\n\n${await cap.inject()}`))).join('\n')}`, hidden: true })
messages.value.push({ role: 'user', content: sendingMessage.value })
messages.value.push(streamingMessage.value)
sendingMessage.value = ''
const parser = createParser({
onComponentDiscovered: (component, index) => {
if (!streamingMessage.value.component_calls) {
streamingMessage.value.component_calls = []
}
streamingMessage.value.component_calls[index] = {
component: {
name: component.component.name,
props: {},
propsLoading: true,
},
index,
type: 'component',
}
},
onComponentPropsLoaded: (component, index) => {
if (streamingMessage.value.component_calls && streamingMessage.value.component_calls[index]) {
streamingMessage.value.component_calls[index].component.props = component.component.props
streamingMessage.value.component_calls[index].component.propsLoading = false
}
},
})
try {
waiting.value = true
const response = await streamText({
baseURL: baseUrl.value,
apiKey: apiKey.value,
model: model.value,
messages: messages.value.slice(0, messages.value.length - 1).map(msg => toRaw(msg)) as UpstreamMessage[],
})
// const response = mockStreamText()
waiting.value = false
for await (const chunk of response.fullStream) {
if (chunk.type === 'text-delta') {
streamingMessage.value.content += chunk.text
try {
if (chunk.text.length > 1) {
for (const char of chunk.text) {
parser.consume(char)
}
}
else {
parser.consume(chunk.text)
}
}
catch {
}
}
}
}
catch (err) {
const errorMessage: ErrorMessage = {
role: 'error',
content: err.message,
}
messages.value.push(errorMessage)
}
finally {
waiting.value = false
}
}
</script>
<template>
<div h-full flex flex-col gap-2>
<div flex="~ col" h-full gap-2>
<div flex flex-col gap-2>
<div>
<span text-neutral-500 dark:text-neutral-400>LLM</span>
</div>
<div grid grid-cols-2 gap-2>
<label flex items-center gap-2>
<span text-nowrap>
Base URL
</span>
<Input v-model="baseUrl" />
</label>
<label flex items-center gap-2>
<span text-nowrap>
API Key
</span>
<Input v-model="apiKey" type="password" />
</label>
<label flex items-center gap-2>
<span text-nowrap>
Model
</span>
<Input v-model="model" />
</label>
</div>
</div>
<div v-if="false">
<template v-for="(componentDef, index) of widgets.components" :key="index">
<component :is="componentDef.component" v-bind="componentDef.exampleProps" :props-loading="true" />
<component :is="componentDef.component" v-bind="componentDef.exampleProps" :props-loading="false" />
</template>
</div>
<div bg="neutral-50/20 dark:neutral-950/20" flex flex-1 flex-col gap-2 rounded-xl p-4 backdrop-blur-lg>
<div v-for="(message, index) of messages" :key="index">
<div v-if="message.role === 'error'" bg="red-100 dark:red-900" w-fit break-words rounded-lg px-3 py-1>
<span>
{{ message.content }}
</span>
</div>
<div v-if="message.role === 'user' && !message.hidden" bg="primary-100 dark:primary-900" w-fit break-words rounded-lg px-3 py-1>
<span>
{{ message.content }}
</span>
</div>
<template v-if="message.role === 'assistant' && message.component_calls && message.component_calls.length">
<template v-for="(componentCall) of message.component_calls" :key="componentCall.index">
<component
:is="widgets.components.find(c => c.name === componentCall.component.name)?.component"
:props-loading="componentCall.component.propsLoading"
v-bind="componentCall.component.props"
/>
</template>
</template>
<template v-else-if="message.role === 'assistant' && !message.component_calls">
<div bg="neutral-100 dark:neutral-800" w-fit break-words rounded-lg px-3 py-1>
<div v-if="index === messages.length - 1 && waiting">
<div i-svg-spinners:3-dots-scale />
</div>
<div v-else-if="message.content">
<span>
{{ message.content }}
</span>
</div>
</div>
</template>
</div>
</div>
<div w-full>
<div>
<Textarea v-model="sendingMessage" @submit="handleChatSendMessage" />
</div>
<button bg="primary-200 dark:primary-900" w-full rounded-lg px-4 py-2 outline-none @click="handleChatSendMessage">
Send
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,10 @@
## Acknowledgements
- [Meteocons | Bas Milius — Full-Stack Developer](https://bas.dev/work/meteocons)
## Many other alternatives
- [Weather Icons by Bas](https://basmilius.github.io/weather-icons/index-fill.html)
- [erikflowers/weather-icons: 215 Weather Themed Icons and CSS](https://github.com/erikflowers/weather-icons)
- [basmilius/weather-icons: Free to use animated weather icons.](https://github.com/basmilius/weather-icons)
- [Makin-Things/weather-icons: A set of updated weather icons based of the AmCharts style of icon.](https://github.com/Makin-Things/weather-icons)
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="a" x1="26.75" y1="22.91" x2="37.25" y2="41.09" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fbbf24"/>
<stop offset="0.45" stop-color="#fbbf24"/>
<stop offset="1" stop-color="#f59e0b"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="10.5" stroke="#f8af18" stroke-miterlimit="10" stroke-width="0.5" fill="url(#a)"/>
<path d="M32,15.71V9.5m0,45V48.29M43.52,20.48l4.39-4.39M16.09,47.91l4.39-4.39m0-23-4.39-4.39M47.91,47.91l-4.39-4.39M15.71,32H9.5m45,0H48.29" fill="none" stroke="#fbbf24" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3">
<animateTransform attributeName="transform" dur="45s" values="0 32 32; 360 32 32" repeatCount="indefinite" type="rotate"/>
</path>
</svg>

After

Width:  |  Height:  |  Size: 817 B

@@ -0,0 +1,75 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
animation?: 'pulse' | 'wave' | 'none'
}>(), {
animation: 'pulse',
})
</script>
<template>
<div
class="skeleton"
:class="props.animation !== 'none' ? `skeleton-${props.animation}` : ''"
bg="neutral-200 dark:neutral-800"
overflow="hidden"
>
<slot />
</div>
</template>
<style scoped>
.skeleton {
position: relative;
transition: all 0.2s ease-in-out;
}
/* Pulse animation */
.skeleton-pulse {
animation: skeleton-pulse 2s ease-in-out 0.5s infinite;
}
@keyframes skeleton-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
/* Wave animation */
.skeleton-wave::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgb(255, 255, 255), transparent);
animation: skeleton-wave 2s ease-in-out infinite;
border-radius: inherit;
}
.dark .skeleton-wave::after {
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
}
@keyframes skeleton-wave {
0% {
transform: translateX(-100%);
opacity: 0;
}
60% {
transform: translateX(100%);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import ClearDay from '../assets/clear-day.svg'
import Skeleton from './Skeleton.vue'
const props = defineProps<{
propsLoading: boolean
city?: string
temperature?: string
condition?: string
}>()
</script>
<template>
<div>
<Skeleton v-if="props.propsLoading" rounded-2xl py-2 pl-3 pr-1 class="grid grid-cols-4 grid-rows-3 max-h-35 gap-2">
<Skeleton animation="wave" class="grid-col-span-3 h-[1lh] w-20% rounded-2xl" />
<div class="col-span-1 row-span-2 h-20 w-20 justify-self-end" />
<Skeleton animation="wave" class="col-span-2 row-span-2 h-full w-20% inline-flex items-end rounded-2xl text-gray-600 font-thin dark:text-gray-300" />
<Skeleton animation="wave" class="col-span-2 row-span-1 h-full w-20% inline-flex items-end justify-self-end rounded-2xl pr-4 text-gray-500 dark:text-gray-400" />
</Skeleton>
<div v-else bg="blue-100 dark:blue-900" rounded-2xl py-2 pl-3 pr-1 class="grid grid-cols-4 grid-rows-3 max-h-35 gap-2">
<div class="grid-col-span-3 text-lg font-semibold">
{{ props.city }}
</div>
<img :src="ClearDay" alt="Weather Icon" class="col-span-1 row-span-2 h-full w-auto justify-self-end">
<div class="col-span-2 row-span-2 h-full inline-flex items-end text-gray-600 font-thin dark:text-gray-300">
<span class="text-[3.5rem] font-thin leading-[1]">{{ props.temperature }}</span>
</div>
<div class="col-span-2 row-span-1 h-full w-full inline-flex items-end justify-end pr-4 text-gray-500 dark:text-gray-400">
<span>{{ props.condition }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,22 @@
import { object, string } from 'valibot'
import Weather from './Weather.vue'
import { defineCCComponent } from '../../plugin-component-calling'
export { default as Weather } from './Weather.vue'
export const weatherComponent = defineCCComponent(
'weather',
Weather,
object({
city: string(),
temperature: string(),
condition: string(),
}),
{
city: 'Tokyo',
temperature: '25°',
condition: 'Sunny',
},
)
@@ -0,0 +1,9 @@
import { weatherComponent } from './components'
export function registerWidgets() {
return {
components: [
weatherComponent,
],
}
}
@@ -0,0 +1,14 @@
import type { Component } from 'vue'
import type { Schema } from 'xsschema'
import { markRaw } from 'vue'
import { toJsonSchema } from 'xsschema'
export function defineCCComponent<T extends Schema>(name: string, component: Component, schema: T, exampleProps?: Record<string, any>) {
return {
name,
schema: toJsonSchema(schema),
component: markRaw(component),
exampleProps,
}
}
@@ -0,0 +1,29 @@
import type { streamText } from '@xsai/stream-text'
type InferStreamType<T> = T extends ReadableStream<infer U> ? U : never
type StreamTextEvent = InferStreamType<Awaited<ReturnType<typeof streamText>>['fullStream']>
export function mockStreamText(): {
fullStream: ReadableStream<StreamTextEvent>
} {
const source = `<component_call><component_name>weather</component_name> \`\`\`json <component_props>{"city":"Shanghai","temperature":"29","condition":"cloudy"}</component_props> \`\`\`</component_call>`
return {
fullStream: new ReadableStream<StreamTextEvent>({
start(controller) {
const text = source.split('')
let index = 0
const interval = setInterval(() => {
if (index < text.length) {
controller.enqueue({ type: 'text-delta', text: text[index] })
index++
}
else {
clearInterval(interval)
controller.close()
}
}, 10)
},
}),
}
}