mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
perf(app): add activity lifecycle management (#579)
This commit is contained in:
@@ -35,10 +35,13 @@ interface AgentAssistantEntryBubbleInput {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
/** 是否允许入口的随机动作、指针跟随和自动贴边,不影响 thinking 等业务状态。 */
|
||||
motionActive?: boolean
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
active: true,
|
||||
motionActive: true,
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
@@ -214,7 +217,7 @@ const {
|
||||
playAction: playAgentPetAction,
|
||||
scheduleRandomAction: scheduleFabRandomAction,
|
||||
} = useAgentPetMachine({
|
||||
active: () => props.active,
|
||||
active: () => props.active && props.motionActive,
|
||||
docked: fabDocked,
|
||||
dragging: fabDragging,
|
||||
pressed: fabPressed,
|
||||
@@ -237,8 +240,7 @@ function getViewportSize() {
|
||||
|
||||
// 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。
|
||||
return {
|
||||
height:
|
||||
visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
height: visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth,
|
||||
}
|
||||
}
|
||||
@@ -826,17 +828,18 @@ function updateFabPointerFromPoint(point: FabPointerPoint) {
|
||||
|
||||
// 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。
|
||||
function queueFabPointerUpdate(clientX: number, clientY: number) {
|
||||
if (!props.active) return
|
||||
if (!props.active || !props.motionActive) return
|
||||
|
||||
fabPendingPointerPoint = { clientX, clientY }
|
||||
if (fabPointerFrame) return
|
||||
|
||||
fabPointerFrame = window.requestAnimationFrame(() => {
|
||||
fabPointerFrame = 0
|
||||
if (!fabPendingPointerPoint) return
|
||||
|
||||
updateFabPointerFromPoint(fabPendingPointerPoint)
|
||||
const point = fabPendingPointerPoint
|
||||
fabPendingPointerPoint = null
|
||||
if (!point || !props.active || !props.motionActive) return
|
||||
|
||||
updateFabPointerFromPoint(point)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -850,22 +853,6 @@ function updateFabPointer(event: PointerEvent) {
|
||||
queueFabPointerUpdate(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 清理入口自动贴边计时器。
|
||||
function clearFabIdleTimer() {
|
||||
if (fabIdleTimer === null) return
|
||||
@@ -895,11 +882,20 @@ function suppressNextFabClick() {
|
||||
// 在入口靠近右侧边缘且空闲时安排自动贴边收起。
|
||||
function scheduleFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || fabRandomAction.value || !shouldFabAutoDock()) return
|
||||
if (
|
||||
!props.active ||
|
||||
!props.motionActive ||
|
||||
fabDocked.value ||
|
||||
hasKeepOpenFabBubbles.value ||
|
||||
fabRandomAction.value ||
|
||||
!shouldFabAutoDock()
|
||||
)
|
||||
return
|
||||
|
||||
fabIdleTimer = window.setTimeout(() => {
|
||||
fabIdleTimer = null
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) return
|
||||
if (!props.active || !props.motionActive || fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock())
|
||||
return
|
||||
|
||||
if (fabRandomAction.value) {
|
||||
scheduleFabAutoDock()
|
||||
@@ -915,14 +911,36 @@ function pauseFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
// 取消挂起的全局指针帧,避免状态切换后迟到回调重新写入位移。
|
||||
function cancelFabPointerUpdate() {
|
||||
if (fabPointerFrame) {
|
||||
window.cancelAnimationFrame(fabPointerFrame)
|
||||
fabPointerFrame = 0
|
||||
}
|
||||
|
||||
fabPendingPointerPoint = null
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
cancelFabPointerUpdate()
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
cancelFabPointerUpdate()
|
||||
window.removeEventListener('pointermove', handleGlobalFabPointer)
|
||||
window.removeEventListener('pointerdown', handleGlobalFabPointer)
|
||||
}
|
||||
@@ -1432,6 +1450,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.motionActive,
|
||||
motionActive => {
|
||||
if (motionActive) {
|
||||
if (props.active && shouldFabAutoDock()) scheduleFabAutoDock()
|
||||
return
|
||||
}
|
||||
|
||||
clearFabIdleTimer()
|
||||
resetFabPointer()
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(clearFabIdleTimer)
|
||||
onScopeDispose(clearFabSuppressNextClickTimer)
|
||||
onScopeDispose(resetFabBubbles)
|
||||
@@ -1752,12 +1783,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-notification::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--v-theme-primary), 0.1),
|
||||
transparent 48%
|
||||
),
|
||||
rgba(var(--v-theme-surface), 0.94);
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-success::before {
|
||||
@@ -1778,11 +1805,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-toast::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.3);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12),
|
||||
transparent 54%
|
||||
),
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), transparent 54%),
|
||||
rgba(var(--v-theme-surface), 0.95);
|
||||
}
|
||||
|
||||
|
||||
@@ -158,9 +158,12 @@ const userStore = useUserStore()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
/** 是否允许面板的装饰性动效,不影响消息流、thinking 或输入反馈。 */
|
||||
motionActive?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
motionActive: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -211,13 +214,11 @@ let pendingMessageScrollToBottom = false
|
||||
let streamPersistTimer: number | null = null
|
||||
let userAbortRequested = false
|
||||
let streamRecoveryTimer: number | null = null
|
||||
let pendingStreamRecovery:
|
||||
| {
|
||||
sessionId: string
|
||||
startedAt: number
|
||||
attempts: number
|
||||
}
|
||||
| null = null
|
||||
let pendingStreamRecovery: {
|
||||
sessionId: string
|
||||
startedAt: number
|
||||
attempts: number
|
||||
} | null = null
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
@@ -250,12 +251,11 @@ const filteredSlashCommands = computed(() => {
|
||||
const query = slashCommandQuery.value
|
||||
if (!query) return slashCommands.value
|
||||
|
||||
return slashCommands.value
|
||||
.filter(command => {
|
||||
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
|
||||
return slashCommands.value.filter(command => {
|
||||
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
// 判断是否展示命令建议浮层。
|
||||
const showSlashCommandMenu = computed(
|
||||
@@ -1956,6 +1956,7 @@ onScopeDispose(() => {
|
||||
<aside
|
||||
v-show="isOpen"
|
||||
class="agent-assistant-panel"
|
||||
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
|
||||
:style="drawerStyle"
|
||||
role="dialog"
|
||||
:aria-label="t('agentAssistant.title')"
|
||||
@@ -2515,13 +2516,16 @@ onScopeDispose(() => {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.24rem;
|
||||
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
|
||||
inline-size: 0.22rem;
|
||||
inset-block-start: 0.16rem;
|
||||
}
|
||||
|
||||
.agent-assistant-panel.is-open:not(.is-motion-paused) .agent-assistant-mini-bot__eye {
|
||||
animation: agent-fab-blink 4.8s ease-in-out 1;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__eye--left {
|
||||
inset-inline-start: 0.22rem;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
function openPanel() {
|
||||
@@ -23,6 +25,17 @@ function handleAssistantPreview(value: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||
<AgentAssistantEntry
|
||||
ref="entryRef"
|
||||
:active="!panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
:thinking="thinking"
|
||||
@open="openPanel"
|
||||
/>
|
||||
<AgentAssistantPanel
|
||||
v-model="panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
@assistant-preview="handleAssistantPreview"
|
||||
@thinking-change="thinking = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantEntry from '@/components/agent/AgentAssistantEntry.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
describe('AgentAssistantEntry lifecycle motion', () => {
|
||||
let animationFrameCallbacks: Map<number, FrameRequestCallback>
|
||||
let nextAnimationFrameId: number
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
animationFrameCallbacks = new Map()
|
||||
nextAnimationFrameId = 1
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId++
|
||||
animationFrameCallbacks.set(id, callback)
|
||||
return id
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
animationFrameCallbacks.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels pointer frames and auto-dock timers when decorative motion stops', async () => {
|
||||
const wrapper = shallowMount(AgentAssistantEntry, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentPetStage: true,
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: true,
|
||||
motionActive: true,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const pointerEvent = new Event('pointermove')
|
||||
Object.assign(pointerEvent, { clientX: 480, clientY: 320 })
|
||||
const frameCountBeforePointer = animationFrameCallbacks.size
|
||||
window.dispatchEvent(pointerEvent)
|
||||
const pointerFrameId = nextAnimationFrameId - 1
|
||||
|
||||
expect(animationFrameCallbacks.size).toBe(frameCountBeforePointer + 1)
|
||||
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await wrapper.setProps({ motionActive: false })
|
||||
await nextTick()
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(pointerFrameId)
|
||||
expect(animationFrameCallbacks.has(pointerFrameId)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_PET_RANDOM_ACTION_MIN_DELAY } from '../agentPetActions'
|
||||
import { useAgentPetMachine } from '../useAgentPetMachine'
|
||||
|
||||
describe('useAgentPetMachine', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('runs finite random actions and clears the queue when decorative motion stops', async () => {
|
||||
const active = ref(true)
|
||||
const docked = ref(false)
|
||||
const dragging = ref(false)
|
||||
const pressed = ref(false)
|
||||
const thinking = ref(false)
|
||||
const scope = effectScope()
|
||||
const machine = scope.run(() =>
|
||||
useAgentPetMachine({
|
||||
active,
|
||||
docked,
|
||||
dragging,
|
||||
pressed,
|
||||
scheduleAutoDock: vi.fn(),
|
||||
shouldAutoDock: () => false,
|
||||
thinking,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(machine).toBeDefined()
|
||||
|
||||
machine?.scheduleRandomAction()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).toBe('wave')
|
||||
|
||||
active.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(machine?.currentAction.value).toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
active.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).not.toBeNull()
|
||||
|
||||
scope.stop()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,6 @@
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
|
||||
background: var(--agent-assistant-robot-outline);
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.18rem;
|
||||
@@ -58,7 +57,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 11px;
|
||||
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-start) 0%,
|
||||
@@ -97,7 +95,6 @@
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.42rem;
|
||||
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.42rem;
|
||||
@@ -136,7 +133,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
|
||||
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
@@ -204,14 +200,12 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
|
||||
inset-inline-start: 0.9rem;
|
||||
transform: rotate(17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
|
||||
inset-inline-start: 3.08rem;
|
||||
transform: rotate(-17deg);
|
||||
transform-origin: top center;
|
||||
@@ -225,13 +219,11 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
|
||||
inset-inline-start: 1.48rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
|
||||
inset-inline-start: 2.46rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
@@ -257,8 +249,7 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
|
||||
filter:
|
||||
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
|
||||
}
|
||||
|
||||
@@ -315,83 +306,6 @@
|
||||
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
|
||||
}
|
||||
|
||||
@keyframes agent-fab-head-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-body-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-antenna-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(12deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-11deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
Reference in New Issue
Block a user