1
1
mirror of https://github.com/leon-ai/leon.git synced 2024-12-25 17:54:43 +03:00

feat(skill/timer): custom timer component

This commit is contained in:
louistiti 2024-08-16 08:12:46 +08:00
parent d77c975800
commit 470889a797
16 changed files with 269 additions and 153 deletions

View File

@ -0,0 +1 @@
export * from './timer'

View File

@ -0,0 +1 @@
export * from './timer'

View File

@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react'
import { CircularProgress, Flexbox, Text } from '@leon-ai/aurora'
interface TimerProps {
initialTime: number
interval: number
totalTimeContent: string
onEnd?: () => void
}
function formatTime(seconds: number): string {
const minutes = seconds >= 60 ? Math.floor(seconds / 60) : 0
const remainingSeconds = seconds % 60
const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes
const formattedSeconds =
remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds
return `${formattedMinutes}:${formattedSeconds}`
}
export function Timer({
initialTime,
interval,
totalTimeContent,
onEnd
}: TimerProps) {
const [progress, setProgress] = useState(0)
const [timeLeft, setTimeLeft] = useState(initialTime)
useEffect(() => {
setTimeLeft(initialTime)
setProgress(0)
}, [initialTime])
useEffect(() => {
if (timeLeft <= 0) {
return
}
const timer = setInterval(() => {
setTimeLeft((prevTime) => {
const newTime = prevTime - 1
if (newTime <= 0 && onEnd) {
onEnd()
}
return newTime
})
setProgress((prevProgress) => prevProgress + 100 / initialTime)
}, interval)
return () => clearInterval(timer)
}, [initialTime, interval, timeLeft])
return (
<CircularProgress value={progress} size="lg">
<Flexbox gap="xs" alignItems="center" justifyContent="center">
<Text fontSize="lg" fontWeight="semi-bold">
{formatTime(timeLeft)}
</Text>
<Text fontSize="xs" secondary>
{totalTimeContent}
</Text>
</Flexbox>
</CircularProgress>
)
}

View File

@ -1,6 +1,8 @@
import { createElement } from 'react' import { createElement } from 'react'
import * as auroraComponents from '@leon-ai/aurora' import * as auroraComponents from '@leon-ai/aurora'
import * as customAuroraComponents from '../custom-aurora-components'
export default function renderAuroraComponent( export default function renderAuroraComponent(
socket, socket,
component, component,
@ -8,7 +10,18 @@ export default function renderAuroraComponent(
) { ) {
if (component) { if (component) {
// eslint-disable-next-line import/namespace // eslint-disable-next-line import/namespace
const reactComponent = auroraComponents[component.component] let reactComponent = auroraComponents[component.component]
/**
* Find custom component if a former component is not found
*/
if (!reactComponent) {
// eslint-disable-next-line import/namespace
reactComponent = customAuroraComponents[component.component]
}
if (!reactComponent) {
console.error(`Component ${component} not found`)
}
// Check if the browsed component has a supported event and bind it // Check if the browsed component has a supported event and bind it
if ( if (

View File

@ -123,35 +123,6 @@ class Leon {
}), }),
supportedEvents: SUPPORTED_WIDGET_EVENTS supportedEvents: SUPPORTED_WIDGET_EVENTS
} }
// dynamically import the TSX component
/*const { default: tsxComponent } = await import(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
'@@/skills/unknown/widget-playground/src/widgets/my-component.tsx'
)
console.log('tsxComponent', tsxComponent)
const componentString = ReactDOMServer.renderToString(
React.createElement(tsxComponent)
)
const componentEventHandlers = {
onClick: () => {}
}
// const componentString = ReactDOMServer.renderToString(tsxComponent)
// Collect event handlers from the component
React.Children.forEach(React.createElement(tsxComponent), (child) => {
if (child.props && child.props.onClick) {
componentEventHandlers.onClick = child.props.onClick.toString()
}
})
const response = {
componentString,
componentEventHandlers
}
console.log('componentString', componentString)
answerObject.output.widgetWithHandlers = response*/
} }
// "Temporize" for the data buffer output on the core // "Temporize" for the data buffer output on the core

View File

@ -7,7 +7,10 @@ interface WidgetEvent {
export const SUPPORTED_WIDGET_EVENTS = [ export const SUPPORTED_WIDGET_EVENTS = [
'onClick', 'onClick',
'onSubmit', 'onSubmit',
'onChange' 'onChange',
'onStart',
'onEnd',
'onFetch'
] as const ] as const
function generateId(): string { function generateId(): string {

View File

@ -3,13 +3,20 @@ import { type WidgetWrapperProps } from '@leon-ai/aurora'
import { SKILL_CONFIG } from '@bridge/constants' import { SKILL_CONFIG } from '@bridge/constants'
import { WidgetComponent } from '@sdk/widget-component' import { WidgetComponent } from '@sdk/widget-component'
type UtteranceSender = 'leon' | 'owner'
interface SendUtteranceWidgetEventMethodParams { interface SendUtteranceWidgetEventMethodParams {
from: UtteranceSender
utterance: string utterance: string
} }
interface RunSkillActionWidgetEventMethodParams { interface RunSkillActionWidgetEventMethodParams {
actionName: string actionName: string
params: Record<string, unknown> params: Record<string, unknown>
} }
interface SendUtteranceOptions {
from?: UtteranceSender
data?: Record<string, unknown>
}
export interface WidgetEventMethod { export interface WidgetEventMethod {
methodName: 'send_utterance' | 'run_skill_action' methodName: 'send_utterance' | 'run_skill_action'
@ -41,18 +48,20 @@ export abstract class Widget<T = unknown> {
/** /**
* Indicate the core to send a given utterance * Indicate the core to send a given utterance
* @param key The key of the content * @param key The key of the content
* @param data The data to apply * @param options The options of the utterance
* @example content('provider_selected', { provider: 'Spotify' }) // 'I chose the Spotify provider' * @example content('provider_selected', { data: { provider: 'Spotify' } }) // 'I chose the Spotify provider'
*/ */
protected sendUtterance( protected sendUtterance(
key: string, key: string,
data?: Record<string, unknown> options?: SendUtteranceOptions
): WidgetEventMethod { ): WidgetEventMethod {
const utteranceContent = this.content(key, data) const utteranceContent = this.content(key, options?.data)
const from = options?.from || 'owner'
return { return {
methodName: 'send_utterance', methodName: 'send_utterance',
methodParams: { methodParams: {
from,
utterance: utteranceContent utterance: utteranceContent
} }
} }
@ -93,6 +102,10 @@ export abstract class Widget<T = unknown> {
let content = widgetContents[key] let content = widgetContents[key]
if (Array.isArray(content)) {
content = content[Math.floor(Math.random() * content.length)] as string
}
if (data) { if (data) {
for (const key in data) { for (const key in data) {
content = content.replaceAll(`%${key}%`, String(data[key])) content = content.replaceAll(`%${key}%`, String(data[key]))

View File

@ -1,5 +1,10 @@
{ {
"endpoints": [ "endpoints": [
{
"method": "GET",
"route": "/api/action/unknown/widget-playground/run",
"params": []
},
{ {
"method": "POST", "method": "POST",
"route": "/api/action/news/github_trends/run", "route": "/api/action/news/github_trends/run",
@ -11,6 +16,58 @@
"route": "/api/action/news/product_hunt_trends/run", "route": "/api/action/news/product_hunt_trends/run",
"params": [] "params": []
}, },
{
"method": "POST",
"route": "/api/action/games/akinator/choose_thematic",
"params": ["thematic"],
"entitiesType": "trim"
},
{
"method": "GET",
"route": "/api/action/games/akinator/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/akinator/guess",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/akinator/retry",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/guess_the_number/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/guess_the_number/guess",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/guess_the_number/replay",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/rochambeau/start",
"params": []
},
{
"method": "POST",
"route": "/api/action/games/rochambeau/play",
"params": ["handsign"],
"entitiesType": "trim"
},
{
"method": "GET",
"route": "/api/action/games/rochambeau/rematch",
"params": []
},
{ {
"method": "POST", "method": "POST",
"route": "/api/action/productivity/todo_list/create_list", "route": "/api/action/productivity/todo_list/create_list",
@ -133,88 +190,6 @@
"route": "/api/action/leon/thanks/run", "route": "/api/action/leon/thanks/run",
"params": [] "params": []
}, },
{
"method": "POST",
"route": "/api/action/games/akinator/choose_thematic",
"params": ["thematic"],
"entitiesType": "trim"
},
{
"method": "GET",
"route": "/api/action/games/akinator/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/akinator/guess",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/akinator/retry",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/guess_the_number/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/guess_the_number/guess",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/guess_the_number/replay",
"params": []
},
{
"method": "GET",
"route": "/api/action/games/rochambeau/start",
"params": []
},
{
"method": "POST",
"route": "/api/action/games/rochambeau/play",
"params": ["handsign"],
"entitiesType": "trim"
},
{
"method": "GET",
"route": "/api/action/games/rochambeau/rematch",
"params": []
},
{
"method": "GET",
"route": "/api/action/unknown/widget-playground/run",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/conversation/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/conversation/chit_chat",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/conversation/converse",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/mbti/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/mbti/quiz",
"params": []
},
{ {
"method": "GET", "method": "GET",
"route": "/api/action/utilities/date_time/current_date_time", "route": "/api/action/utilities/date_time/current_date_time",
@ -305,6 +280,31 @@
"method": "GET", "method": "GET",
"route": "/api/action/utilities/translator-poc/translate", "route": "/api/action/utilities/translator-poc/translate",
"params": [] "params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/conversation/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/conversation/chit_chat",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/conversation/converse",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/mbti/setup",
"params": []
},
{
"method": "GET",
"route": "/api/action/social_communication/mbti/quiz",
"params": []
} }
] ]
} }

View File

@ -180,17 +180,20 @@ export default class SocketServer {
}) })
// Listen for widget events // Listen for widget events
this.socket?.on('widget-event', (event: WidgetDataEvent) => { this.socket?.on('widget-event', async (event: WidgetDataEvent) => {
LogHelper.title('Socket') LogHelper.title('Socket')
LogHelper.info(`Widget event: ${JSON.stringify(event)}`) LogHelper.info(`Widget event: ${JSON.stringify(event)}`)
const { method } = event const { method } = event
if (method.methodName === 'send_utterance') { if (method.methodName === 'send_utterance') {
this.socket?.emit( const utterance = method.methodParams['utterance']
'widget-send-utterance',
method.methodParams['utterance'] if (method.methodParams['from'] === 'leon') {
) await BRAIN.talk(utterance as string, true)
} else {
this.socket?.emit('widget-send-utterance', utterance)
}
} else if (method.methodName === 'run_skill_action') { } else if (method.methodName === 'run_skill_action') {
this.socket?.emit('widget-run-skill-action', method.methodParams) this.socket?.emit('widget-run-skill-action', method.methodParams)
} }

View File

@ -286,7 +286,12 @@ export const skillConfigSchemaObject = Type.Strict(
) )
), ),
answers: Type.Optional(Type.Record(Type.String(), Type.Array(answerTypes))), answers: Type.Optional(Type.Record(Type.String(), Type.Array(answerTypes))),
widget_contents: Type.Optional(Type.Record(Type.String(), Type.String())), widget_contents: Type.Optional(
Type.Record(
Type.String(),
Type.Union([Type.String(), Type.Array(Type.String())])
)
),
entities: Type.Optional(Type.Record(Type.String(), Type.String())), entities: Type.Optional(Type.Record(Type.String(), Type.String())),
resolvers: Type.Optional( resolvers: Type.Optional(
Type.Record( Type.Record(

View File

@ -29,7 +29,9 @@ export class PlaygroundTestWidget extends Widget<Params> {
children: provider, children: provider,
onClick: (): WidgetEventMethod => { onClick: (): WidgetEventMethod => {
return this.sendUtterance('choose_provider', { return this.sendUtterance('choose_provider', {
data: {
provider provider
}
}) })
} }
}) })

View File

@ -59,5 +59,13 @@
"Sorry, I can't set a timer for this unit. Use %hours%, %minutes% or %seconds% instead.", "Sorry, I can't set a timer for this unit. Use %hours%, %minutes% or %seconds% instead.",
"I can't set a timer for this duration. Use %hours%, %minutes% or %seconds% instead." "I can't set a timer for this duration. Use %hours%, %minutes% or %seconds% instead."
] ]
},
"widget_contents": {
"second_unit": "second",
"seconds_unit": "seconds",
"minutes_unit": "minutes",
"minute_unit": "minute",
"total_time": "Total %value% %unit%",
"times_up": ["Time's up!", "The timer is up!", "The timer has ended!"]
} }
} }

View File

@ -3,10 +3,6 @@ import { leon } from '@sdk/leon'
import { TimerWidget } from '../widgets/timer' import { TimerWidget } from '../widgets/timer'
function secondsToMinutes(seconds: number): number {
return seconds / 60
}
export const run: ActionFunction = async function (params) { export const run: ActionFunction = async function (params) {
const supportedUnits = ['hours', 'minutes', 'seconds'] const supportedUnits = ['hours', 'minutes', 'seconds']
const durations = ( const durations = (
@ -24,12 +20,17 @@ export const run: ActionFunction = async function (params) {
} }
const { value: durationValue } = duration const { value: durationValue } = duration
const seconds = Number(durationValue)
const timerWidget = new TimerWidget({ const timerWidget = new TimerWidget({
params: { params: {
minutes: secondsToMinutes(Number(durationValue)) seconds
} }
}) })
// TODO: return a speech without new utterance
/*await leon.answer({
widget: timerWidget,
speech: 'I set a timer for ... ...'
})*/
await leon.answer({ widget: timerWidget }) await leon.answer({ widget: timerWidget })
} }

View File

@ -0,0 +1,14 @@
import { WidgetComponent } from '@sdk/widget-component'
interface TimerProps {
initialTime: number
interval: number
totalTimeContent: string
onEnd?: () => void
}
export class Timer extends WidgetComponent<TimerProps> {
constructor(props: TimerProps) {
super(props)
}
}

View File

@ -1,9 +1,10 @@
import { Widget, type WidgetOptions } from '@sdk/widget' import { Widget, type WidgetEventMethod, type WidgetOptions } from '@sdk/widget'
import { type WidgetComponent } from '@sdk/widget-component' import { type WidgetComponent } from '@sdk/widget-component'
import { Flexbox, CircularProgress, Text } from '@sdk/aurora'
import { Timer } from './components/timer'
interface Params { interface Params {
minutes: number seconds: number
} }
export class TimerWidget extends Widget<Params> { export class TimerWidget extends Widget<Params> {
@ -15,30 +16,41 @@ export class TimerWidget extends Widget<Params> {
* TODO * TODO
* 1. Save timer + timer id in memory * 1. Save timer + timer id in memory
* 2. On rendering, set widget id to timer id * 2. On rendering, set widget id to timer id
* 3. When load feed, need to fetch all timers as per their timer id. Need a built-in API here * 3. When load feed, need to fetch all timers (onFetch?) as per their timer id. Need a built-in API here
* 4. onEnd (or onChange and check if done?), then trigger next action or utterance
*/ */
public render(): WidgetComponent { public render(): WidgetComponent {
return new CircularProgress({ const { seconds } = this.params
value: 0, const secondUnitContent = this.content('second_unit')
size: 'lg', const secondsUnitContent = this.content('seconds_unit')
children: new Flexbox({ const minuteUnitContent = this.content('minute_unit')
gap: 'xs', const minutesUnitContent = this.content('minutes_unit')
alignItems: 'center', let totalTimeContent = ''
justifyContent: 'center',
children: [ if (seconds >= 60) {
new Text({ const minutes = seconds / 60
fontSize: 'lg',
fontWeight: 'semi-bold', totalTimeContent = this.content('total_time', {
children: 0 value: minutes % 1 === 0 ? minutes : minutes.toFixed(2),
}), unit: minutes > 1 ? minutesUnitContent : minuteUnitContent
new Text({
fontSize: 'xs',
secondary: true,
children: 'Total 10 minutes'
}) })
] } else {
totalTimeContent = this.content('total_time', {
value: seconds,
unit: seconds > 1 ? secondsUnitContent : secondUnitContent
}) })
}
return new Timer({
initialTime: seconds,
interval: 1_000,
totalTimeContent,
onEnd: (): WidgetEventMethod => {
return this.sendUtterance('times_up', {
from: 'leon'
})
}
}) })
} }
} }

View File

@ -6,6 +6,7 @@
"baseUrl": ".", "baseUrl": ".",
"moduleResolution": "Node", "moduleResolution": "Node",
"module": "CommonJS", "module": "CommonJS",
"jsx": "react",
"paths": { "paths": {
"@@/*": ["./*"], "@@/*": ["./*"],
"@/*": ["./server/src/*"], "@/*": ["./server/src/*"],