feat(component-calling): or component use, init
This commit is contained in:
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
+13
@@ -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 |
+75
@@ -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>
|
||||
+35
@@ -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>
|
||||
+22
@@ -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)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user