refactor(ui): move ui and i18n to new package

This commit is contained in:
Simon
2025-12-15 17:20:12 +08:00
parent 0b46fd1d7a
commit 2b8b6ef86a
12 changed files with 0 additions and 0 deletions

View File

@@ -1,50 +0,0 @@
import {
type SupportedLanguage,
type TranslationKey,
type TranslationParams,
type TranslationSchema,
locales,
} from './locales'
export class I18n {
private language: SupportedLanguage
private translations: TranslationSchema
constructor(language: SupportedLanguage = 'en-US') {
this.language = language in locales ? language : 'en-US'
this.translations = locales[language]
}
// 类型安全的翻译方法
t(key: TranslationKey, params?: TranslationParams): string {
const value = this.getNestedValue(this.translations, key)
if (!value) {
console.warn(`Translation key "${key}" not found for language "${this.language}"`)
return key
}
if (params) {
return this.interpolate(value, params)
}
return value
}
private getNestedValue(obj: any, path: string): string | undefined {
return path.split('.').reduce((current, key) => current?.[key], obj)
}
private interpolate(template: string, params: TranslationParams): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
// Use != null to check for both null and undefined, allow empty strings
return params[key] != null ? params[key].toString() : match
})
}
getLanguage(): SupportedLanguage {
return this.language
}
}
// 导出类型和实例创建函数
export type { TranslationKey, SupportedLanguage, TranslationParams }
export { locales }

View File

@@ -1,126 +0,0 @@
// English translations (base/reference language)
const enUS = {
ui: {
panel: {
ready: 'Ready',
thinking: 'Thinking...',
paused: 'Paused',
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
taskTerminated: 'Task terminated',
taskCompleted: 'Task completed',
continueExecution: 'Continue execution',
userAnswer: 'User answer: {{input}}',
question: 'Question: {{question}}',
waitingPlaceholder: 'Waiting for task to start...',
pause: 'Pause',
continue: 'Continue',
stop: 'Stop',
expand: 'Expand history',
collapse: 'Collapse history',
step: 'Step {{number}} · {{time}}{{duration}}',
},
tools: {
clicking: 'Clicking element [{{index}}]...',
inputting: 'Inputting text to element [{{index}}]...',
selecting: 'Selecting option "{{text}}"...',
scrolling: 'Scrolling page...',
waiting: 'Waiting {{seconds}} seconds...',
done: 'Task done',
clicked: '🖱️ Clicked element [{{index}}]',
inputted: '⌨️ Inputted text "{{text}}"',
selected: '☑️ Selected option "{{text}}"',
scrolled: '🛞 Page scrolled',
waited: '⌛️ Wait completed',
executing: 'Executing {{toolName}}...',
resultSuccess: 'success',
resultFailure: 'failed',
resultError: 'error',
},
errors: {
elementNotFound: 'No interactive element found at index {{index}}',
taskRequired: 'Task description is required',
executionFailed: 'Task execution failed',
notInputElement: 'Element is not an input or textarea',
notSelectElement: 'Element is not a select element',
optionNotFound: 'Option "{{text}}" not found',
},
},
} as const
// Chinese translations (must match the structure of enUS)
const zhCN = {
ui: {
panel: {
ready: '准备就绪',
thinking: '正在思考...',
paused: '暂停中,稍后',
taskInput: '输入新任务,详细描述步骤,回车提交',
userAnswerPrompt: '请回答上面问题,回车提交',
taskTerminated: '任务已终止',
taskCompleted: '任务结束',
continueExecution: '继续执行',
userAnswer: '用户回答: {{input}}',
question: '询问: {{question}}',
waitingPlaceholder: '等待任务开始...',
pause: '暂停',
continue: '继续',
stop: '终止',
expand: '展开历史',
collapse: '收起历史',
step: '步骤 {{number}} · {{time}}{{duration}}',
},
tools: {
clicking: '正在点击元素 [{{index}}]...',
inputting: '正在输入文本到元素 [{{index}}]...',
selecting: '正在选择选项 "{{text}}"...',
scrolling: '正在滚动页面...',
waiting: '等待 {{seconds}} 秒...',
done: '结束任务',
clicked: '🖱️ 已点击元素 [{{index}}]',
inputted: '⌨️ 已输入文本 "{{text}}"',
selected: '☑️ 已选择选项 "{{text}}"',
scrolled: '🛞 页面滚动完成',
waited: '⌛️ 等待完成',
executing: '正在执行 {{toolName}}...',
resultSuccess: '成功',
resultFailure: '失败',
resultError: '错误',
},
errors: {
elementNotFound: '未找到索引为 {{index}} 的交互元素',
taskRequired: '任务描述不能为空',
executionFailed: '任务执行失败',
notInputElement: '元素不是输入框或文本域',
notSelectElement: '元素不是选择框',
optionNotFound: '未找到选项 "{{text}}"',
},
},
} as const
// Type definitions generated from English base structure (but with string values)
type DeepStringify<T> = {
[K in keyof T]: T[K] extends string ? string : T[K] extends object ? DeepStringify<T[K]> : T[K]
}
export type TranslationSchema = DeepStringify<typeof enUS>
// Utility type: Extract all nested paths from translation object
type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: `${Key}`
}[keyof ObjectType & (string | number)]
// Extract all possible key paths from translation structure
export type TranslationKey = NestedKeyOf<TranslationSchema>
// Parameterized translation types
export type TranslationParams = Record<string, string | number>
export const locales = {
'en-US': enUS,
'zh-CN': zhCN,
} as const
export type SupportedLanguage = keyof typeof locales

View File

@@ -1,597 +0,0 @@
.wrapper {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%) translateY(20px);
opacity: 0;
z-index: 2147483642; /* 比 SimulatorMask 高一层 */
box-sizing: border-box;
overflow: visible;
* {
box-sizing: border-box;
}
--width: 360px;
--height: 40px;
--border-radius: 12px;
--side-space: 12px; /* 控制栏两侧的间距 */
--history-width: calc(var(--width) - var(--side-space) * 2);
--color-1: rgb(57, 182, 255);
--color-2: rgb(189, 69, 251);
--color-3: rgb(255, 87, 51);
--color-4: rgb(255, 214, 0);
width: var(--width);
height: var(--height);
transition: all 0.3s ease-in-out;
/* 响应式设计 */
@media (max-width: 480px) {
width: calc(100vw - 40px);
--width: calc(100vw - 40px);
}
.background {
position: absolute;
inset: -2px -8px;
border-radius: calc(var(--border-radius) + 4px);
filter: blur(16px);
overflow: hidden;
/* mix-blend-mode: lighten; */
/* display: none; */
&::before {
content: '';
z-index: -1;
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
/* left: -100%; */
left: 0;
top: 0;
background-image: linear-gradient(
to bottom left,
var(--color-1),
var(--color-2),
var(--color-1)
);
animation: mask-running 2s linear infinite;
}
&::after {
content: '';
z-index: -1;
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-image: linear-gradient(
to bottom left,
var(--color-2),
var(--color-1),
var(--color-2)
);
animation: mask-running 2s linear infinite;
animation-delay: 1s;
}
}
}
@keyframes mask-running {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
/* 控制栏 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
user-select: none;
position: absolute;
inset: 0;
cursor: pointer;
flex-shrink: 0; /* 防止 header 被压缩 */
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
border-radius: var(--border-radius);
background-clip: padding-box;
box-shadow:
0 0 0px 2px rgba(255, 255, 255, 0.4),
0 0 5px 1px rgba(255, 255, 255, 0.3);
.statusSection {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-height: 24px; /* 确保垂直居中 */
.indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
animation: none; /* 默认无动画 */
/* 运行状态 - 有动画 */
&.thinking {
background: rgb(57, 182, 255);
animation: pulse 0.8s ease-in-out infinite;
}
&.tool_executing {
background: rgb(189, 69, 251);
animation: pulse 0.6s ease-in-out infinite;
}
&.retry {
background: rgb(255, 214, 0);
animation: retryPulse 1s ease-in-out infinite;
}
/* 静止状态 - 无动画 */
&.completed,
&.input,
&.output {
background: rgb(34, 197, 94);
animation: none;
}
&.error {
background: rgb(239, 68, 68);
animation: none;
}
}
.statusText {
color: white;
font-size: 12px;
line-height: 1;
font-weight: 500;
transition: all 0.3s ease-in-out;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
min-height: 24px; /* 确保垂直居中 */
&.fadeOut {
animation: statusTextFadeOut 0.3s ease forwards;
}
&.fadeIn {
animation: statusTextFadeIn 0.3s ease forwards;
}
}
}
.controls {
display: flex;
align-items: center;
gap: 4px;
.controlButton {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
.pauseButton {
font-weight: 600;
&.paused {
background: rgba(34, 197, 94, 0.2); /* 绿色背景表示可以继续 */
color: rgb(34, 197, 94);
&:hover {
background: rgba(34, 197, 94, 0.3);
}
}
}
.stopButton {
background: rgba(239, 68, 68, 0.2);
color: rgb(255, 41, 41);
font-weight: 600;
&:hover {
background: rgba(239, 68, 68, 0.3);
}
}
}
}
@keyframes statusTextFadeIn {
0% {
opacity: 0;
transform: translateY(5px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes statusTextFadeOut {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-5px);
}
}
.historySectionWrapper {
position: absolute;
width: var(--history-width);
bottom: var(--height);
left: var(--side-space);
z-index: -2;
padding-top: 0px;
visibility: collapse;
overflow: hidden;
transition: all 0.2s;
background: rgba(2, 0, 20, 0.5);
/* background: rgba(186, 186, 186, 0.2); */
backdrop-filter: blur(10px);
text-shadow: 0 0 1px rgba(0, 0, 0, 0.2);
border-top-left-radius: calc(var(--border-radius) + 4px);
border-top-right-radius: calc(var(--border-radius) + 4px);
/* border: 2px solid rgba(255, 255, 255, 0.8); */
border: 2px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
/* @media (prefers-color-scheme: dark) {
box-shadow:
0 8px 32px 0 rgba(0, 0, 0, 0.85),
0 2px 12px 0 rgba(57, 182, 255, 0.1);
} */
.expanded & {
padding-top: 8px;
visibility: visible;
}
.historySection {
position: relative;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
max-height: 0;
padding-inline: 8px;
transition: max-height 0.2s;
.expanded & {
max-height: 400px;
}
.historyItem {
/* backdrop-filter: blur(10px); */
padding: 8px 10px;
margin-bottom: 6px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
border-radius: 8px;
border-left: 2px solid rgba(57, 182, 255, 0.5);
font-size: 12px;
color: white;
/* color: black; */
line-height: 1.3;
position: relative;
overflow: hidden;
/* 微妙的内阴影 */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 1px 3px rgba(0, 0, 0, 0.1);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
}
&:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.06));
/* transform: translateY(-1px); */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
0 2px 4px rgba(0, 0, 0, 0.15);
}
&:last-child {
margin-bottom: 10px;
}
&.completed,
&.input,
&.output {
border-left-color: rgb(34, 197, 94);
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05));
}
&.error {
border-left-color: rgb(239, 68, 68);
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(239, 68, 68, 0.05));
}
&.retry {
border-left-color: rgb(255, 214, 0);
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
}
/* 突出显示 done 成功结果 */
&.doneSuccess {
background: linear-gradient(
135deg,
rgba(34, 197, 94, 0.25),
rgba(34, 197, 94, 0.15),
rgba(34, 197, 94, 0.08)
);
border: none;
border-left: 4px solid rgb(34, 197, 94);
box-shadow:
0 4px 12px rgba(34, 197, 94, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 0 20px rgba(34, 197, 94, 0.1);
font-weight: 600;
color: rgb(220, 252, 231);
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 8px;
position: relative;
overflow: hidden;
&::before {
background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.4), transparent);
}
&::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
animation: shimmer 2s ease-in-out infinite;
}
.historyContent {
.statusIcon {
font-size: 16px;
animation: celebrate 0.8s ease-in-out;
filter: drop-shadow(0 2px 4px rgba(34, 197, 94, 0.5));
}
}
}
/* 突出显示 done 失败结果 */
&.doneError {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.25),
rgba(239, 68, 68, 0.15),
rgba(239, 68, 68, 0.08)
);
border: none;
border-left: 4px solid rgb(239, 68, 68);
box-shadow:
0 4px 12px rgba(239, 68, 68, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 0 20px rgba(239, 68, 68, 0.1);
font-weight: 600;
color: rgb(254, 226, 226);
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 8px;
position: relative;
overflow: hidden;
&::before {
background: linear-gradient(90deg, transparent, rgba(239, 68, 68, 0.4), transparent);
}
.historyContent {
.statusIcon {
font-size: 16px;
filter: drop-shadow(0 2px 4px rgba(239, 68, 68, 0.5));
}
}
}
.historyContent {
display: flex;
align-items: center;
gap: 8px;
word-break: break-all;
white-space: pre-wrap;
/* overflow-x: auto; */
.statusIcon {
font-size: 12px;
flex-shrink: 0;
line-height: 1;
transition: all 0.3s ease;
}
}
.historyMeta {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
/* color: rgb(61, 61, 61); */
margin-top: 8px;
line-height: 1;
}
}
}
}
/* 动画关键帧 - 更快的闪烁 */
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.3);
}
}
/* 重试动画 - 旋转脉冲 */
@keyframes retryPulse {
0%,
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
25% {
opacity: 0.6;
transform: scale(1.2) rotate(90deg);
}
50% {
opacity: 0.8;
transform: scale(1.1) rotate(180deg);
}
75% {
opacity: 0.6;
transform: scale(1.2) rotate(270deg);
}
}
/* 庆祝动画 */
@keyframes celebrate {
0%,
100% {
transform: scale(1);
}
25% {
transform: scale(1.2) rotate(-5deg);
}
75% {
transform: scale(1.2) rotate(5deg);
}
}
/* done 卡片的光泽效果 */
@keyframes shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
/* 输入区域样式 */
.inputSectionWrapper {
position: absolute;
width: var(--history-width);
top: var(--height);
left: var(--side-space);
z-index: -1;
visibility: visible;
overflow: hidden;
height: 48px;
transition: all 0.2s;
background: rgba(186, 186, 186, 0.2);
backdrop-filter: blur(10px);
border-bottom-left-radius: calc(var(--border-radius) + 4px);
border-bottom-right-radius: calc(var(--border-radius) + 4px);
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 1px 16px rgba(0, 0, 0, 0.4);
&.hidden {
visibility: collapse;
height: 0;
}
.inputSection {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 8px;
.taskInput {
flex: 1;
background: rgba(255, 255, 255, 0.4);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 10px;
padding-inline: 10px;
color: rgb(20, 20, 20);
font-size: 12px;
height: 28px;
line-height: 1;
outline: none;
transition: all 0.2s ease;
/* text-shadow: 0 0 2px rgba(255, 255, 255, 0.8); */
/* border-color: rgba(57, 182, 255, 0.3); */
&::placeholder {
color: rgb(53, 53, 53);
}
&:focus {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(57, 182, 255, 0.6);
box-shadow: 0 0 0 2px rgba(57, 182, 255, 0.2);
}
}
}
}

View File

@@ -1,671 +0,0 @@
import { I18n, type SupportedLanguage } from '../i18n'
import { truncate } from '../utils'
import { type Step, UIState } from './UIState'
import styles from './Panel.module.css'
/**
* Panel configuration
*/
export interface PanelConfig {
language?: SupportedLanguage
onExecuteTask: (task: string) => void
onStop: () => void
onPauseToggle: () => boolean // returns new paused state
getPaused: () => boolean
}
/**
* Semantic update types - Panel handles i18n internally
*/
export type PanelUpdate =
| { type: 'thinking'; text?: string } // text is optional, defaults to i18n thinking text
| { type: 'input'; task: string }
| { type: 'question'; question: string }
| { type: 'userAnswer'; input: string }
| { type: 'retry'; current: number; max: number }
| { type: 'error'; message: string }
| { type: 'output'; text: string }
| { type: 'completed' }
| { type: 'toolExecuting'; toolName: string; args: any }
| { type: 'toolCompleted'; toolName: string; args: any; result?: string; duration?: number }
/**
* Agent control panel
*/
export class Panel {
#wrapper: HTMLElement
#indicator: HTMLElement
#statusText: HTMLElement
#historySection: HTMLElement
#expandButton: HTMLElement
#pauseButton: HTMLElement
#stopButton: HTMLElement
#inputSection: HTMLElement
#taskInput: HTMLInputElement
#state = new UIState()
#isExpanded = false
#config: PanelConfig
#i18n: I18n
#userAnswerResolver: ((input: string) => void) | null = null
#isWaitingForUserAnswer: boolean = false
#headerUpdateTimer: ReturnType<typeof setInterval> | null = null
#pendingHeaderText: string | null = null
#isAnimating = false
get wrapper(): HTMLElement {
return this.#wrapper
}
constructor(config: PanelConfig) {
this.#config = config
this.#i18n = new I18n(config.language ?? 'en-US')
this.#wrapper = this.#createWrapper()
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
this.#pauseButton = this.#wrapper.querySelector(`.${styles.pauseButton}`)!
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
this.#setupEventListeners()
this.#startHeaderUpdateLoop()
this.#showInputArea()
}
/**
* Ask for user input
*/
async askUser(question: string): Promise<string> {
return new Promise((resolve) => {
// Set `waiting for user answer` state
this.#isWaitingForUserAnswer = true
this.#userAnswerResolver = resolve
// Update state to `running`
this.#updateInternal({
type: 'output',
displayText: this.#i18n.t('ui.panel.question', { question }),
}) // Expand history panel
if (!this.#isExpanded) {
this.#expand()
}
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
})
}
// ========== Public control methods ==========
show(): void {
this.wrapper.style.display = 'block'
void this.wrapper.offsetHeight
this.wrapper.style.opacity = '1'
this.wrapper.style.transform = 'translateX(-50%) translateY(0)'
}
hide(): void {
this.wrapper.style.opacity = '0'
this.wrapper.style.transform = 'translateX(-50%) translateY(20px)'
this.wrapper.style.display = 'none'
}
reset(): void {
this.#state.reset()
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
this.#updateStatusIndicator('thinking')
this.#updateHistory()
this.#collapse()
// Reset pause state via callback
if (this.#config.getPaused()) {
this.#config.onPauseToggle()
}
this.#updatePauseButton()
// Reset user input state
this.#isWaitingForUserAnswer = false
this.#userAnswerResolver = null
// Show input area
this.#showInputArea()
}
expand(): void {
this.#expand()
}
collapse(): void {
this.#collapse()
}
/**
* Update panel with semantic data - i18n handled internally
*/
update(data: PanelUpdate): void {
const stepData = this.#toStepData(data)
this.#updateInternal(stepData)
}
/**
* Dispose panel
*/
dispose(): void {
this.#isWaitingForUserAnswer = false
this.#stopHeaderUpdateLoop()
this.wrapper.remove()
}
// ========== Private methods ==========
/**
* Convert semantic update to step data with i18n
*/
#toStepData(data: PanelUpdate): Omit<Step, 'id' | 'stepNumber' | 'timestamp'> {
switch (data.type) {
case 'thinking':
return { type: 'thinking', displayText: data.text ?? this.#i18n.t('ui.panel.thinking') }
case 'input':
return { type: 'input', displayText: data.task }
case 'question':
return {
type: 'output',
displayText: this.#i18n.t('ui.panel.question', { question: data.question }),
}
case 'userAnswer':
return {
type: 'input',
displayText: this.#i18n.t('ui.panel.userAnswer', { input: data.input }),
}
case 'retry':
return { type: 'retry', displayText: `retry-ing (${data.current} / ${data.max})` }
case 'error':
return { type: 'error', displayText: data.message }
case 'output':
return { type: 'output', displayText: data.text }
case 'completed':
return { type: 'completed', displayText: this.#i18n.t('ui.panel.taskCompleted') }
case 'toolExecuting':
return {
type: 'tool_executing',
toolName: data.toolName,
toolArgs: data.args,
displayText: this.#getToolExecutingText(data.toolName, data.args),
}
case 'toolCompleted': {
const displayText = this.#getToolCompletedText(data.toolName, data.args)
if (!displayText) return { type: 'tool_executing', displayText: '' } // will be filtered
return {
type: 'tool_executing',
toolName: data.toolName,
toolArgs: data.args,
toolResult: data.result,
displayText,
duration: data.duration,
}
}
}
}
#getToolExecutingText(toolName: string, args: any): string {
switch (toolName) {
case 'click_element_by_index':
return this.#i18n.t('ui.tools.clicking', { index: args.index })
case 'input_text':
return this.#i18n.t('ui.tools.inputting', { index: args.index })
case 'select_dropdown_option':
return this.#i18n.t('ui.tools.selecting', { text: args.text })
case 'scroll':
return this.#i18n.t('ui.tools.scrolling')
case 'wait':
return this.#i18n.t('ui.tools.waiting', { seconds: args.seconds })
case 'done':
return this.#i18n.t('ui.tools.done')
default:
return this.#i18n.t('ui.tools.executing', { toolName })
}
}
#getToolCompletedText(toolName: string, args: any): string | null {
switch (toolName) {
case 'click_element_by_index':
return this.#i18n.t('ui.tools.clicked', { index: args.index })
case 'input_text':
return this.#i18n.t('ui.tools.inputted', { text: args.text })
case 'select_dropdown_option':
return this.#i18n.t('ui.tools.selected', { text: args.text })
case 'scroll':
return this.#i18n.t('ui.tools.scrolled')
case 'wait':
return this.#i18n.t('ui.tools.waited')
case 'done':
return null
default:
return null
}
}
/**
* Update status (internal)
*/
#updateInternal(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
// Skip empty displayText (filtered toolCompleted for 'done')
if (!stepData.displayText) return
const step = this.#state.addStep(stepData)
// Queue header text update (will be processed by periodic check)
const headerText = truncate(step.displayText, 20)
this.#pendingHeaderText = headerText
this.#updateStatusIndicator(step.type)
this.#updateHistory()
// Auto-expand history after task completion
if (step.type === 'completed' || step.type === 'error') {
if (!this.#isExpanded) {
this.#expand()
}
}
// Control input area display based on status
if (this.#shouldShowInputArea()) {
this.#showInputArea()
} else {
this.#hideInputArea()
}
}
/**
* Toggle pause state
*/
#togglePause(): void {
const paused = this.#config.onPauseToggle()
this.#updatePauseButton()
// Update status display
if (paused) {
this.#statusText.textContent = this.#i18n.t('ui.panel.paused')
this.#updateStatusIndicator('thinking')
} else {
this.#statusText.textContent = this.#i18n.t('ui.panel.continueExecution')
this.#updateStatusIndicator('tool_executing')
}
}
/**
* Update pause button state
*/
#updatePauseButton(): void {
const paused = this.#config.getPaused()
if (paused) {
this.#pauseButton.textContent = '▶'
this.#pauseButton.title = this.#i18n.t('ui.panel.continue')
this.#pauseButton.classList.add(styles.paused)
} else {
this.#pauseButton.textContent = '⏸︎'
this.#pauseButton.title = this.#i18n.t('ui.panel.pause')
this.#pauseButton.classList.remove(styles.paused)
}
}
/**
* Stop Agent
*/
#stopAgent(): void {
// Update status display
this.#updateInternal({
type: 'error',
displayText: this.#i18n.t('ui.panel.taskTerminated'),
})
this.#config.onStop()
}
/**
* Submit task
*/
#submitTask() {
const input = this.#taskInput.value.trim()
if (!input) return
// Hide input area
this.#hideInputArea()
if (this.#isWaitingForUserAnswer) {
// Handle user input mode
this.#handleUserAnswer(input)
} else {
this.#config.onExecuteTask(input)
}
}
/**
* Handle user answer
*/
#handleUserAnswer(input: string): void {
// Add user input to history
this.#updateInternal({
type: 'input',
displayText: this.#i18n.t('ui.panel.userAnswer', { input }),
})
// Reset state
this.#isWaitingForUserAnswer = false
// Call resolver to return user input
if (this.#userAnswerResolver) {
this.#userAnswerResolver(input)
this.#userAnswerResolver = null
}
}
/**
* Show input area
*/
#showInputArea(placeholder?: string): void {
// Clear input field
this.#taskInput.value = ''
this.#taskInput.placeholder = placeholder || this.#i18n.t('ui.panel.taskInput')
this.#inputSection.classList.remove(styles.hidden)
// Focus on input field
setTimeout(() => {
this.#taskInput.focus()
}, 100)
}
/**
* Hide input area
*/
#hideInputArea(): void {
this.#inputSection.classList.add(styles.hidden)
}
/**
* Check if input area should be shown
*/
#shouldShowInputArea(): boolean {
// Always show input area if waiting for user input
if (this.#isWaitingForUserAnswer) return true
const steps = this.#state.getAllSteps()
if (steps.length === 0) {
return true // Initial state
}
const lastStep = steps[steps.length - 1]
return lastStep.type === 'completed' || lastStep.type === 'error'
}
#createWrapper(): HTMLElement {
const wrapper = document.createElement('div')
wrapper.id = 'page-agent-runtime_agent-panel'
wrapper.className = `${styles.wrapper} ${styles.collapsed}`
wrapper.setAttribute('data-browser-use-ignore', 'true')
wrapper.innerHTML = `
<div class="${styles.background}"></div>
<div class="${styles.historySectionWrapper}">
<div class="${styles.historySection}">
${this.#createHistoryItem({
id: 'placeholder',
stepNumber: 0,
timestamp: new Date(),
type: 'thinking',
displayText: this.#i18n.t('ui.panel.waitingPlaceholder'),
})}
</div>
</div>
<div class="${styles.header}">
<div class="${styles.statusSection}">
<div class="${styles.indicator} ${styles.thinking}"></div>
<div class="${styles.statusText}">${this.#i18n.t('ui.panel.ready')}</div>
</div>
<div class="${styles.controls}">
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
</button>
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#i18n.t('ui.panel.pause')}">
⏸︎
</button>
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
X
</button>
</div>
</div>
<div class="${styles.inputSectionWrapper} ${styles.hidden}">
<div class="${styles.inputSection}">
<input
type="text"
class="${styles.taskInput}"
maxlength="200"
/>
</div>
</div>
`
document.body.appendChild(wrapper)
return wrapper
}
#setupEventListeners(): void {
// Click header area to expand/collapse
const header = this.wrapper.querySelector(`.${styles.header}`)!
header.addEventListener('click', (e) => {
// Don't trigger expand/collapse if clicking on buttons
if ((e.target as HTMLElement).closest(`.${styles.controlButton}`)) {
return
}
this.#toggle()
})
// Expand button
this.#expandButton.addEventListener('click', (e) => {
e.stopPropagation()
this.#toggle()
})
// Pause/continue button
this.#pauseButton.addEventListener('click', (e) => {
e.stopPropagation()
this.#togglePause()
})
// Stop button
this.#stopButton.addEventListener('click', (e) => {
e.stopPropagation()
this.#stopAgent()
})
// Submit on Enter key in input field
this.#taskInput.addEventListener('keydown', (e) => {
if (e.isComposing) return // Ignore IME composition keys
if (e.key === 'Enter') {
e.preventDefault()
this.#submitTask()
}
})
// Prevent input area click event bubbling
this.#inputSection.addEventListener('click', (e) => {
e.stopPropagation()
})
}
#toggle(): void {
if (this.#isExpanded) {
this.#collapse()
} else {
this.#expand()
}
}
#expand(): void {
this.#isExpanded = true
this.wrapper.classList.remove(styles.collapsed)
this.wrapper.classList.add(styles.expanded)
this.#expandButton.textContent = '▲'
}
#collapse(): void {
this.#isExpanded = false
this.wrapper.classList.remove(styles.expanded)
this.wrapper.classList.add(styles.collapsed)
this.#expandButton.textContent = '▼'
}
/**
* Start periodic header update loop
*/
#startHeaderUpdateLoop(): void {
// Check every 450ms (same as total animation duration)
this.#headerUpdateTimer = setInterval(() => {
this.#checkAndUpdateHeader()
}, 450)
}
/**
* Stop periodic header update loop
*/
#stopHeaderUpdateLoop(): void {
if (this.#headerUpdateTimer) {
clearInterval(this.#headerUpdateTimer)
this.#headerUpdateTimer = null
}
}
/**
* Check if header needs update and trigger animation if not currently animating
*/
#checkAndUpdateHeader(): void {
// If no pending text or currently animating, skip
if (!this.#pendingHeaderText || this.#isAnimating) {
return
}
// If text is already displayed, clear pending and skip
if (this.#statusText.textContent === this.#pendingHeaderText) {
this.#pendingHeaderText = null
return
}
// Start animation
const textToShow = this.#pendingHeaderText
this.#pendingHeaderText = null
this.#animateTextChange(textToShow)
}
/**
* Animate text change with fade out/in effect
*/
#animateTextChange(newText: string): void {
this.#isAnimating = true
// Fade out current text
this.#statusText.classList.add(styles.fadeOut)
setTimeout(() => {
// Update text content
this.#statusText.textContent = newText
// Fade in new text
this.#statusText.classList.remove(styles.fadeOut)
this.#statusText.classList.add(styles.fadeIn)
setTimeout(() => {
this.#statusText.classList.remove(styles.fadeIn)
this.#isAnimating = false
}, 300)
}, 150) // Half the duration of fade out animation
}
#updateStatusIndicator(type: Step['type']): void {
// Clear all status classes
this.#indicator.className = styles.indicator
// Add corresponding status class
this.#indicator.classList.add(styles[type])
}
#updateHistory(): void {
const steps = this.#state.getAllSteps()
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
// Scroll to bottom to show latest records
this.#scrollToBottom()
}
#scrollToBottom(): void {
// Execute in next event loop to ensure DOM update completion
setTimeout(() => {
this.#historySection.scrollTop = this.#historySection.scrollHeight
}, 0)
}
#createHistoryItem(step: Step): string {
const time = step.timestamp.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
let typeClass = ''
let statusIcon = ''
// Set styles and icons based on step type
if (step.type === 'completed') {
// Check if this is a result from done tool
if (step.toolName === 'done') {
// Judge success or failure based on result
const failureKeyword = this.#i18n.t('ui.tools.resultFailure')
const errorKeyword = this.#i18n.t('ui.tools.resultError')
const isSuccess =
!step.toolResult ||
(!step.toolResult.includes(failureKeyword) && !step.toolResult.includes(errorKeyword))
typeClass = isSuccess ? styles.doneSuccess : styles.doneError
statusIcon = isSuccess ? '🎉' : '❌'
} else {
typeClass = styles.completed
statusIcon = '✅'
}
} else if (step.type === 'error') {
typeClass = styles.error
statusIcon = '❌'
} else if (step.type === 'tool_executing') {
statusIcon = '⚙️'
} else if (step.type === 'output') {
typeClass = styles.output
statusIcon = '🤖'
} else if (step.type === 'input') {
typeClass = styles.input
statusIcon = '🎯'
} else if (step.type === 'retry') {
typeClass = styles.retry
statusIcon = '🔄'
} else {
statusIcon = '🧠'
}
const durationText = step.duration ? ` · ${step.duration}ms` : ''
const stepLabel = this.#i18n.t('ui.panel.step', {
number: step.stepNumber.toString(),
time,
duration: durationText || '', // Explicitly pass empty string to replace template
})
return `
<div class="${styles.historyItem} ${typeClass}">
<div class="${styles.historyContent}">
<span class="${styles.statusIcon}">${statusIcon}</span>
<span>${step.displayText}</span>
</div>
<div class="${styles.historyMeta}">
${stepLabel}
</div>
</div>
`
}
}

View File

@@ -1,10 +0,0 @@
.wrapper {
position: fixed;
inset: 0;
z-index: 2147483641; /* 确保在所有元素之上,除了 panel */
/* pointer-events: none; */
cursor: not-allowed;
overflow: hidden;
display: none;
}

View File

@@ -1,172 +0,0 @@
import { Motion } from 'ai-motion'
import { isPageDark } from '../utils/checkDarkMode'
import styles from './SimulatorMask.module.css'
import cursorStyles from './cursor.module.css'
export class SimulatorMask {
wrapper = document.createElement('div')
motion = new Motion({
mode: isPageDark() ? 'dark' : 'light',
styles: {
position: 'absolute',
inset: '0',
},
})
#cursor = document.createElement('div')
#currentCursorX = 0
#currentCursorY = 0
#targetCursorX = 0
#targetCursorY = 0
constructor() {
this.wrapper.id = 'page-agent-runtime_simulator-mask'
this.wrapper.className = styles.wrapper
this.wrapper.setAttribute('data-browser-use-ignore', 'true')
this.wrapper.appendChild(this.motion.element)
this.motion.autoResize(this.wrapper)
// Capture all mouse, keyboard, and wheel events
this.wrapper.addEventListener('click', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('mousedown', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('mouseup', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('mousemove', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('wheel', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('keydown', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('keyup', (e) => {
e.stopPropagation()
e.preventDefault()
})
// Create AI cursor
this.#createCursor()
// this.show()
document.body.appendChild(this.wrapper)
this.#moveCursorToTarget()
window.addEventListener('PageAgent::MovePointerTo', (event: Event) => {
const { x, y } = (event as CustomEvent).detail
this.setCursorPosition(x, y)
})
window.addEventListener('PageAgent::ClickPointer', (event: Event) => {
this.triggerClickAnimation()
})
}
#createCursor() {
this.#cursor.className = cursorStyles.cursor
// Create ripple effect container
const rippleContainer = document.createElement('div')
rippleContainer.className = cursorStyles.cursorRipple
this.#cursor.appendChild(rippleContainer)
// Create filling layer
const fillingLayer = document.createElement('div')
fillingLayer.className = cursorStyles.cursorFilling
this.#cursor.appendChild(fillingLayer)
// Create border layer
const borderLayer = document.createElement('div')
borderLayer.className = cursorStyles.cursorBorder
this.#cursor.appendChild(borderLayer)
this.wrapper.appendChild(this.#cursor)
}
#moveCursorToTarget() {
const newX = this.#currentCursorX + (this.#targetCursorX - this.#currentCursorX) * 0.2
const newY = this.#currentCursorY + (this.#targetCursorY - this.#currentCursorY) * 0.2
const xDistance = Math.abs(newX - this.#targetCursorX)
if (xDistance > 0) {
if (xDistance < 2) {
this.#currentCursorX = this.#targetCursorX
} else {
this.#currentCursorX = newX
}
this.#cursor.style.left = `${this.#currentCursorX}px`
}
const yDistance = Math.abs(newY - this.#targetCursorY)
if (yDistance > 0) {
if (yDistance < 2) {
this.#currentCursorY = this.#targetCursorY
} else {
this.#currentCursorY = newY
}
this.#cursor.style.top = `${this.#currentCursorY}px`
}
requestAnimationFrame(() => this.#moveCursorToTarget())
}
setCursorPosition(x: number, y: number) {
this.#targetCursorX = x
this.#targetCursorY = y
}
triggerClickAnimation() {
this.#cursor.classList.remove(cursorStyles.clicking)
// Force reflow to restart animation
void this.#cursor.offsetHeight
this.#cursor.classList.add(cursorStyles.clicking)
}
show() {
this.motion.start()
this.motion.fadeIn()
this.wrapper.style.display = 'block'
// Initialize cursor position
this.#currentCursorX = window.innerWidth / 2
this.#currentCursorY = window.innerHeight / 2
this.#targetCursorX = this.#currentCursorX
this.#targetCursorY = this.#currentCursorY
this.#cursor.style.left = `${this.#currentCursorX}px`
this.#cursor.style.top = `${this.#currentCursorY}px`
}
hide() {
this.motion.fadeOut()
this.motion.pause()
this.#cursor.classList.remove(cursorStyles.clicking)
setTimeout(() => {
this.wrapper.style.display = 'none'
}, 800) // Match the animation duration
}
dispose() {
this.motion.dispose()
this.wrapper.remove()
}
}

View File

@@ -1,93 +0,0 @@
/**
* Agent execution state management
*/
export interface Step {
id: string
stepNumber: number
timestamp: Date
type: 'thinking' | 'tool_executing' | 'completed' | 'error' | 'output' | 'input' | 'retry'
// Tool execution related
toolName?: string
toolArgs?: any
toolResult?: any
// Display data
displayText: string
duration?: number
}
export type AgentStatus = 'idle' | 'running' | 'paused' | 'completed' | 'error'
export class UIState {
private steps: Step[] = []
private currentStep: Step | null = null
private status: AgentStatus = 'idle'
private stepCounter = 0
addStep(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): Step {
const step: Step = {
id: this.generateId(),
stepNumber: ++this.stepCounter,
timestamp: new Date(),
...stepData,
}
this.steps.push(step)
this.currentStep = step
// Update overall status
this.updateStatus(step.type)
return step
}
updateCurrentStep(updates: Partial<Step>): Step | null {
if (!this.currentStep) return null
Object.assign(this.currentStep, updates)
return this.currentStep
}
getCurrentStep(): Step | null {
return this.currentStep
}
getAllSteps(): Step[] {
return [...this.steps]
}
getStatus(): AgentStatus {
return this.status
}
reset(): void {
this.steps = []
this.currentStep = null
this.status = 'idle'
this.stepCounter = 0
}
private updateStatus(stepType: Step['type']): void {
switch (stepType) {
case 'thinking':
case 'tool_executing':
case 'output':
case 'input':
case 'retry':
this.status = 'running'
break
case 'completed':
this.status = 'completed'
break
case 'error':
this.status = 'error'
break
}
}
private generateId(): string {
return `step_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
}
}

View File

@@ -1,91 +0,0 @@
/* AI 光标样式 */
.cursor {
position: absolute;
width: var(--cursor-size, 75px);
height: var(--cursor-size, 75px);
pointer-events: none;
z-index: 10000;
transform: translate(-30%, -30%);
animation: cursor-enter 300ms ease-out forwards;
}
.cursorBorder {
position: absolute;
inset: 0;
background: linear-gradient(45deg, rgb(57, 182, 255), rgb(189, 69, 251));
mask-image: url(https://img.alicdn.com/imgextra/i1/O1CN01YHLVYR1LvqWIyo5kH_!!6000000001362-2-tps-202-202.png);
mask-size: 100% 100%;
mask-repeat: no-repeat;
animation: cursor-breathe 2s ease-in-out infinite;
}
.cursorFilling {
position: absolute;
inset: 0;
background: url(https://img.alicdn.com/imgextra/i3/O1CN01JZOqOS1Tu1sIKbPLW_!!6000000002441-2-tps-202-202.png);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.cursorRipple {
position: absolute;
inset: 0;
pointer-events: none;
}
.cursor.clicking .cursorRipple::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: -30%;
top: -30%;
border: 4px solid rgba(57, 182, 255, 1);
border-radius: 50%;
animation: cursor-ripple 300ms ease-out forwards;
}
/* 光标动画关键帧 */
@keyframes cursor-breathe {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.05);
opacity: 1;
}
}
@keyframes cursor-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes cursor-enter {
0% {
transform: translate(-30%, -30%) scale(0.5);
opacity: 0;
}
100% {
transform: translate(-30%, -30%) scale(1);
opacity: 1;
}
}
@keyframes cursor-ripple {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}

View File

@@ -1,64 +0,0 @@
import styles from './motion.module.css'
export function createMotion() {
const wrapper = document.createElement('div')
wrapper.className = styles.wrapper
{
const colorWrapper = document.createElement('div')
colorWrapper.className = styles.colorWrapper
wrapper.appendChild(colorWrapper)
const layerA = document.createElement('div')
layerA.className = styles.colorLayer + ' ' + styles.layerA
colorWrapper.appendChild(layerA)
const layerB = document.createElement('div')
layerB.className = styles.colorLayer + ' ' + styles.layerB
colorWrapper.appendChild(layerB)
const layerC = document.createElement('div')
layerC.className = styles.colorLayer + ' ' + styles.layerC
colorWrapper.appendChild(layerC)
}
{
const borderWrapper = document.createElement('div')
borderWrapper.className = styles.borderWrapper
wrapper.appendChild(borderWrapper)
const layerA = document.createElement('div')
layerA.className = styles.borderLayer + ' ' + styles.layerA
borderWrapper.appendChild(layerA)
const layerB = document.createElement('div')
layerB.className = styles.borderLayer + ' ' + styles.layerB
borderWrapper.appendChild(layerB)
const layerC = document.createElement('div')
layerC.className = styles.borderLayer + ' ' + styles.layerC
borderWrapper.appendChild(layerC)
}
function show() {
wrapper.classList.remove(styles.exit)
wrapper.classList.remove(styles.entry)
// Force reflow to restart animation
void wrapper.offsetHeight
wrapper.classList.add(styles.entry)
}
function hide() {
wrapper.classList.remove(styles.entry)
wrapper.classList.remove(styles.exit)
// Force reflow to restart animation
void wrapper.offsetHeight
wrapper.classList.add(styles.exit)
}
return {
element: wrapper,
show,
hide,
}
}

View File

@@ -1,397 +0,0 @@
.wrapper {
position: absolute;
inset: 0;
pointer-events: none;
transform-origin: center;
--color-1: rgb(57, 182, 255);
--color-2: rgb(189, 69, 251);
--color-3: rgb(255, 87, 51);
--color-4: rgb(255, 214, 0);
--blend-mode: screen;
}
.colorLayer {
position: absolute;
inset: 0;
/* 变亮混合模式 */
/* mix-blend-mode: screen; */
/* mix-blend-mode: overlay; */
/* mix-blend-mode: multiply; */
mix-blend-mode: add;
/* 边框遮罩 - 中间透明,边缘不透明 */
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
mask-repeat: no-repeat;
mask-size: calc(100% + 10px) calc(100% + 10px);
}
.borderWrapper {
position: absolute;
inset: 0;
/* filter: blur(10px); */
}
.borderLayer {
position: absolute;
inset: 0;
/* 变亮混合模式 */
/* mix-blend-mode: overlay; */
mix-blend-mode: add;
mask-image:
linear-gradient(
to right,
black 0px,
black 2px,
transparent 2px,
transparent calc(100% - 2px),
black calc(100% - 2px),
black 100%
),
linear-gradient(
to top,
black 0px,
black 2px,
transparent 2px,
transparent calc(100% - 2px),
black calc(100% - 2px),
black 100%
);
mask-composite: add;
mask-repeat: no-repeat;
mask-size: 100% 100%;
/* filter: blur(100px); */
}
.blueLayer {
&.colorLayer {
mask-position: left -5px top -5px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(57, 182, 255);
animation: rotate-clockwise 4s linear infinite;
animation-delay: -3s;
}
}
.purpleLayer {
&.colorLayer {
mask-position: left -3px top -7px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(189, 69, 251);
animation: rotate-clockwise 4s linear infinite;
animation-delay: -2s;
}
}
.orangeLayer {
/* opacity: 0.5; */
&.colorLayer {
mask-position: left -7px top -2px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(255, 87, 51);
animation: rotate-counter-clockwise 3s linear infinite;
animation-delay: -2s;
}
}
.yellowLayer {
/* opacity: 0.5; */
&.colorLayer {
mask-position: left -6px top -4px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(255, 214, 0);
animation: rotate-counter-clockwise 4s linear infinite;
animation-delay: -1s;
}
}
/* 旋转动画 */
@keyframes rotate-clockwise {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(360deg);
}
}
@keyframes rotate-counter-clockwise {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(-360deg);
}
}
@keyframes wrapper-entry {
from {
transform: scale(1.1);
}
to {
transform: scale(1);
}
}
/*
rgb(57, 182, 255)
rgb(189, 69, 251)
rgb(255, 87, 51)
rgb(255, 214, 0)
*/
@keyframes mask-running {
from {
transform: translateX(0%);
}
to {
transform: translateX(100%);
}
}
@keyframes mask-running-reverse {
from {
transform: translateX(100%);
}
to {
transform: translateX(0%);
}
}
.colorWrapper {
position: absolute;
inset: 0;
.colorLayer {
position: absolute;
inset: 0;
mix-blend-mode: var(--blend-mode);
/* 边框遮罩 - 中间透明,边缘不透明 */
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
mask-repeat: no-repeat;
mask-size: 100% 100%;
}
}
.borderWrapper {
position: absolute;
inset: 0;
--blend-mode: lighten;
.borderLayer {
position: absolute;
inset: 0;
mix-blend-mode: var(--blend-mode);
mask-border: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
25;
-webkit-mask-box-image: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
25;
mask-repeat: no-repeat;
mask-size: 100% 100%;
background-color: var(--color-2);
}
}
.entry .colorWrapper,
.entry .borderWrapper {
animation: wrapper-entry 0.8s ease-in-out forwards;
}
.exit .colorWrapper,
.exit .borderWrapper {
animation: wrapper-entry 0.8s ease-in-out reverse forwards;
}
.layerA {
position: absolute;
inset: 0;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background-image: linear-gradient(
to right bottom,
transparent,
var(--color-1),
transparent,
var(--color-1),
transparent
);
animation: mask-running 2s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-image: linear-gradient(
to right bottom,
transparent,
var(--color-1),
transparent,
var(--color-1),
transparent
);
animation: mask-running 2s linear infinite;
}
}
.layerB {
position: absolute;
inset: 0;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-2),
transparent,
var(--color-2),
transparent
);
animation: mask-running-reverse 3s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-2),
transparent,
var(--color-2),
transparent
);
animation: mask-running-reverse 3s linear infinite;
}
}
.layerC {
position: absolute;
inset: 0;
opacity: 0.5;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-3),
transparent,
var(--color-3),
transparent
);
animation: mask-running 1s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-3),
transparent,
var(--color-3),
transparent
);
animation: mask-running 1s linear infinite;
}
}

View File

@@ -1,5 +0,0 @@
This is the CSS implementation of ai-motion.
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
Use it only in a small area.

View File

@@ -1,110 +0,0 @@
/**
* Checks for common dark mode CSS classes on the html or body elements.
* @returns {boolean} - True if a common dark mode class is found.
*/
function hasDarkModeClass() {
const DFEAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
const htmlElement = document.documentElement
const bodyElement = document.body
// Check class names on <html> and <body>
for (const className of DFEAULT_DARK_MODE_CLASSES) {
if (htmlElement.classList.contains(className) || bodyElement.classList.contains(className)) {
return true
}
}
// Some sites use data attributes
const darkThemeAttribute = htmlElement.getAttribute('data-theme')
if (darkThemeAttribute?.toLowerCase().includes('dark')) {
return true
}
return false
}
/**
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
* @returns {{r: number, g: number, b: number}|null}
*/
function parseRgbColor(colorString: string) {
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
if (!rgbMatch) {
return null // Not a valid rgb/rgba string
}
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
}
}
/**
* Determines if a color is "dark" based on its calculated luminance.
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
* @returns {boolean} - True if the color is considered dark.
*/
function isColorDark(colorString: string, threshold = 128) {
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
return false // Transparent is not dark
}
const rgb = parseRgbColor(colorString)
if (!rgb) {
return false // Could not parse color
}
// Calculate perceived luminance using the standard formula
const luminance = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
return luminance < threshold
}
/**
* Checks the background color of the body element to determine if the page is dark.
* @returns {boolean}
*/
function isBackgroundDark() {
// We check both <html> and <body> because some pages set the color on <html>
const htmlStyle = window.getComputedStyle(document.documentElement)
const bodyStyle = window.getComputedStyle(document.body)
// Get background colors
const htmlBgColor = htmlStyle.backgroundColor
const bodyBgColor = bodyStyle.backgroundColor
// The body's background might be transparent, in which case we should
// fall back to the html element's background.
if (isColorDark(bodyBgColor)) {
return true
} else if (bodyBgColor === 'transparent' || bodyBgColor.startsWith('rgba(0, 0, 0, 0)')) {
return isColorDark(htmlBgColor)
}
return false
}
/**
* A comprehensive function to determine if the page is currently in a dark theme.
* It combines class checking and background color analysis.
* @returns {boolean} - True if the page is likely dark.
*/
export function isPageDark() {
// Strategy 1: Check for common dark mode classes
if (hasDarkModeClass()) {
return true
}
// Strategy 2: Analyze the computed background color
if (isBackgroundDark()) {
return true
}
// @TODO add more checks here, e.g., analyzing text color,
// or checking the background of major layout elements like <main> or #app.
return false
}