feat: init
This commit is contained in:
598
src/ui/Panel.module.css
Normal file
598
src/ui/Panel.module.css
Normal file
@@ -0,0 +1,598 @@
|
||||
.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);
|
||||
left: 20px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
548
src/ui/Panel.ts
Normal file
548
src/ui/Panel.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import type { PageAgent } from '@/PageAgent'
|
||||
import type { I18n } from '@/i18n'
|
||||
import { truncate } from '@/utils'
|
||||
import type { EventBus } from '@/utils/bus'
|
||||
|
||||
import { type Step, UIState } from './UIState'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
/**
|
||||
* Agent control panel
|
||||
*/
|
||||
export class Panel {
|
||||
#wrapper: HTMLElement
|
||||
#indicator: HTMLElement
|
||||
#statusText: HTMLElement
|
||||
#historySection: HTMLElement
|
||||
#expandButton: HTMLElement
|
||||
#pauseButton: HTMLElement
|
||||
#stopButton: HTMLElement
|
||||
#inputSection: HTMLElement
|
||||
#taskInput: HTMLInputElement
|
||||
#bus: EventBus
|
||||
|
||||
#state = new UIState()
|
||||
#isExpanded = false
|
||||
#pageAgent: PageAgent
|
||||
#userAnswerResolver: ((input: string) => void) | null = null
|
||||
#isWaitingForUserAnswer: boolean = false
|
||||
|
||||
get wrapper(): HTMLElement {
|
||||
return this.#wrapper
|
||||
}
|
||||
|
||||
constructor(pageAgent: PageAgent) {
|
||||
this.#pageAgent = pageAgent
|
||||
this.#bus = pageAgent.bus
|
||||
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.#expand() // debug
|
||||
|
||||
this.#showInputArea()
|
||||
|
||||
this.#bus.on('panel:show', () => this.#show())
|
||||
this.#bus.on('panel:hide', () => this.#hide())
|
||||
this.#bus.on('panel:reset', () => this.#reset())
|
||||
this.#bus.on('panel:update', (stepData) => this.#update(stepData))
|
||||
this.#bus.on('panel:expand', () => this.#expand())
|
||||
this.#bus.on('panel:collapse', () => this.#collapse())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.#update({
|
||||
type: 'output',
|
||||
displayText: `询问: ${question}`,
|
||||
})
|
||||
|
||||
// Expand history panel
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
this.#showInputArea(this.#pageAgent.i18n.t('ui.panel.userAnswerPrompt'))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose panel
|
||||
*/
|
||||
dispose(): void {
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.wrapper.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status
|
||||
*/
|
||||
async #update(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): Promise<void> {
|
||||
const step = this.#state.addStep(stepData)
|
||||
|
||||
// Show animation if text changes
|
||||
const headerText = truncate(step.displayText, 20)
|
||||
if (this.#statusText.textContent !== headerText) {
|
||||
await this.#animateTextChange(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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show panel
|
||||
*/
|
||||
#show(): void {
|
||||
this.wrapper.style.display = 'block'
|
||||
// Force reflow to trigger animation
|
||||
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.#pageAgent.i18n.t('ui.panel.ready')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#updateHistory()
|
||||
this.#collapse()
|
||||
// Reset pause state
|
||||
this.#pageAgent.paused = false
|
||||
this.#updatePauseButton()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
// Show input area
|
||||
this.#showInputArea()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
#togglePause(): void {
|
||||
this.#pageAgent.paused = !this.#pageAgent.paused
|
||||
this.#updatePauseButton()
|
||||
|
||||
// Update status display
|
||||
if (this.#pageAgent.paused) {
|
||||
this.#statusText.textContent = '暂停中,稍后'
|
||||
this.#updateStatusIndicator('thinking') // Use existing thinking state
|
||||
} else {
|
||||
this.#statusText.textContent = '继续执行'
|
||||
this.#updateStatusIndicator('tool_executing') // Restore to execution state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新暂停按钮状态
|
||||
*/
|
||||
#updatePauseButton(): void {
|
||||
if (this.#pageAgent.paused) {
|
||||
this.#pauseButton.textContent = '▶'
|
||||
this.#pauseButton.title = '继续'
|
||||
this.#pauseButton.classList.add(styles.paused)
|
||||
} else {
|
||||
this.#pauseButton.textContent = '⏸︎'
|
||||
this.#pauseButton.title = '暂停'
|
||||
this.#pauseButton.classList.remove(styles.paused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 终止 Agent
|
||||
*/
|
||||
#stopAgent(): void {
|
||||
// Update status display
|
||||
this.#update({
|
||||
type: 'error',
|
||||
displayText: '任务已终止',
|
||||
})
|
||||
|
||||
this.#pageAgent.dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交任务
|
||||
*/
|
||||
#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.#pageAgent.execute(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户回答
|
||||
*/
|
||||
#handleUserAnswer(input: string): void {
|
||||
// Add user input to history
|
||||
this.#update({
|
||||
type: 'input',
|
||||
displayText: `用户回答: ${input}`,
|
||||
})
|
||||
|
||||
// Reset state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
|
||||
// Call resolver to return user input
|
||||
if (this.#userAnswerResolver) {
|
||||
this.#userAnswerResolver(input)
|
||||
this.#userAnswerResolver = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示输入区域
|
||||
*/
|
||||
#showInputArea(placeholder?: string): void {
|
||||
// Clear input field
|
||||
this.#taskInput.value = ''
|
||||
this.#taskInput.placeholder = placeholder || '输入新任务,详细描述步骤,回车提交'
|
||||
this.#inputSection.classList.remove(styles.hidden)
|
||||
// Focus on input field
|
||||
setTimeout(() => {
|
||||
this.#taskInput.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏输入区域
|
||||
*/
|
||||
#hideInputArea(): void {
|
||||
this.#inputSection.classList.add(styles.hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该显示输入区域
|
||||
*/
|
||||
#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: '等待任务开始...',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.header}">
|
||||
<div class="${styles.statusSection}">
|
||||
<div class="${styles.indicator} ${styles.thinking}"></div>
|
||||
<div class="${styles.statusText}">准备就绪</div>
|
||||
</div>
|
||||
<div class="${styles.controls}">
|
||||
<button class="${styles.controlButton} ${styles.expandButton}" title="展开历史">
|
||||
▼
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.pauseButton}" title="暂停">
|
||||
⏸︎
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="终止">
|
||||
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 = '▼'
|
||||
}
|
||||
|
||||
async #animateTextChange(newText: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// 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)
|
||||
resolve()
|
||||
}, 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
|
||||
.slice(-10) // Only show last 10 items
|
||||
.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') {
|
||||
// @todo not right
|
||||
// Judge success or failure based on result
|
||||
const isSuccess =
|
||||
!step.toolResult ||
|
||||
(!step.toolResult.includes('失败') && !step.toolResult.includes('错误'))
|
||||
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 = '🧠'
|
||||
}
|
||||
|
||||
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}">
|
||||
步骤 ${step.stepNumber} · ${time}
|
||||
${step.duration ? ` · ${step.duration}ms` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具执行时的显示文本
|
||||
*/
|
||||
export function getToolExecutingText(toolName: string, args: any, i18n: I18n): string {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return i18n.t('ui.tools.clicking', { index: args.index })
|
||||
case 'input_text':
|
||||
return i18n.t('ui.tools.inputting', { index: args.index })
|
||||
case 'select_dropdown_option':
|
||||
return i18n.t('ui.tools.selecting', { text: args.text })
|
||||
case 'scroll':
|
||||
return i18n.t('ui.tools.scrolling')
|
||||
case 'wait':
|
||||
return i18n.t('ui.tools.waiting', { seconds: args.seconds })
|
||||
case 'done':
|
||||
return i18n.t('ui.tools.done')
|
||||
default:
|
||||
return i18n.t('ui.tools.executing', { toolName })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具完成时的显示文本
|
||||
*/
|
||||
export function getToolCompletedText(toolName: string, args: any, i18n: I18n): string | null {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return i18n.t('ui.tools.clicked', { index: args.index })
|
||||
case 'input_text':
|
||||
return i18n.t('ui.tools.inputted', { text: args.text })
|
||||
case 'select_dropdown_option':
|
||||
return i18n.t('ui.tools.selected', { text: args.text })
|
||||
case 'scroll':
|
||||
return i18n.t('ui.tools.scrolled')
|
||||
case 'wait':
|
||||
return i18n.t('ui.tools.waited')
|
||||
case 'done':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
10
src/ui/SimulatorMask.module.css
Normal file
10
src/ui/SimulatorMask.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483641; /* 确保在所有元素之上,除了 panel */
|
||||
/* pointer-events: none; */
|
||||
cursor: not-allowed;
|
||||
overflow: hidden;
|
||||
|
||||
display: none;
|
||||
}
|
||||
172
src/ui/SimulatorMask.ts
Normal file
172
src/ui/SimulatorMask.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
93
src/ui/UIState.ts
Normal file
93
src/ui/UIState.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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)}`
|
||||
}
|
||||
}
|
||||
91
src/ui/cursor.module.css
Normal file
91
src/ui/cursor.module.css
Normal file
@@ -0,0 +1,91 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
64
src/ui/motion-css/createMotion.ts
Normal file
64
src/ui/motion-css/createMotion.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
397
src/ui/motion-css/motion.module.css
Normal file
397
src/ui/motion-css/motion.module.css
Normal file
@@ -0,0 +1,397 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
5
src/ui/motion-css/readme
Normal file
5
src/ui/motion-css/readme
Normal file
@@ -0,0 +1,5 @@
|
||||
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.
|
||||
Reference in New Issue
Block a user