thread-master/frontend/src/lib/islander/actionExecutor.ts

226 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { NavigateFunction } from 'react-router-dom'
import { ISLANDER_CONFIG } from './config'
import {
capturePageSnapshot,
findElementByLabel,
formatPageSnapshot,
resolveSnapshotElement,
} from './pageSnapshot'
import type {
IslanderAction,
IslanderActionHandler,
IslanderActionResult,
IslanderExecutorContext,
PageSnapshot,
} from './types'
const customHandlers = new Map<string, IslanderActionHandler>()
/** Register custom action handlers for future page-specific extensions. */
export function registerIslanderActionHandler(type: string, handler: IslanderActionHandler) {
customHandlers.set(type, handler)
}
function waitMs(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(() => resolve(), ms)
})
}
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
const proto =
el instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
const previous = el.value
setter?.call(el, value)
const tracker = (el as HTMLInputElement & { _valueTracker?: { setValue: (v: string) => void } })
._valueTracker
if (tracker) {
tracker.setValue(previous)
}
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
function resolveFillTarget(
action: Extract<IslanderAction, { type: 'fill' }>,
ctx: IslanderExecutorContext,
): HTMLInputElement | HTMLTextAreaElement | null {
if (action.ref) {
const byRef = ctx.resolveRef(action.ref)
if (byRef instanceof HTMLInputElement || byRef instanceof HTMLTextAreaElement) return byRef
}
if (action.label) {
const byLabel = findElementByLabel(action.label)
if (byLabel instanceof HTMLInputElement || byLabel instanceof HTMLTextAreaElement) return byLabel
}
return null
}
function flashHighlight(el: HTMLElement) {
el.classList.add(ISLANDER_CONFIG.highlightClass)
window.setTimeout(() => el.classList.remove(ISLANDER_CONFIG.highlightClass), ISLANDER_CONFIG.highlightDurationMs)
}
function isBlockedClick(el: HTMLElement) {
const label = (el.getAttribute('aria-label') ?? el.textContent ?? '').trim()
return ISLANDER_CONFIG.blockedClickPatterns.some((pattern) => pattern.test(label))
}
function buildExecutorContext(
navigate: NavigateFunction,
snapshot: PageSnapshot,
): IslanderExecutorContext {
return {
navigate: (path) => navigate(path),
snapshot,
waitMs,
highlight: flashHighlight,
resolveRef: (ref) => resolveSnapshotElement(snapshot.refMap, ref),
}
}
async function runBuiltinAction(
action: IslanderAction,
ctx: IslanderExecutorContext,
): Promise<IslanderActionResult> {
switch (action.type) {
case 'wait': {
const ms = Math.min(Math.max(action.ms ?? 300, 0), 3000)
await ctx.waitMs(ms)
return { action, ok: true, detail: `waited ${ms}ms` }
}
case 'navigate': {
const path = action.path.startsWith('/') ? action.path : `/${action.path}`
if (!path.startsWith('/') || path.startsWith('//')) {
return { action, ok: false, detail: '只允許站內路徑' }
}
ctx.navigate(path)
await ctx.waitMs(ISLANDER_CONFIG.navigateWaitMs)
return { action, ok: true, detail: path }
}
case 'highlight': {
const el = ctx.resolveRef(action.ref)
if (!el) return { action, ok: false, detail: '找不到元素' }
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
ctx.highlight(el)
return { action, ok: true, detail: el.textContent?.trim().slice(0, 40) ?? action.ref }
}
case 'focus': {
const el = ctx.resolveRef(action.ref)
if (!el) return { action, ok: false, detail: '找不到元素' }
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
if ('focus' in el && typeof el.focus === 'function') el.focus()
ctx.highlight(el)
return { action, ok: true, detail: action.ref }
}
case 'scroll': {
if (action.ref) {
const el = ctx.resolveRef(action.ref)
if (!el) return { action, ok: false, detail: '找不到元素' }
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
return { action, ok: true, detail: action.ref }
}
window.scrollTo({ top: action.top ?? 0, behavior: 'smooth' })
return { action, ok: true, detail: `top=${action.top ?? 0}` }
}
case 'click': {
const el = ctx.resolveRef(action.ref)
if (!el) return { action, ok: false, detail: '找不到元素' }
if (isBlockedClick(el)) return { action, ok: false, detail: '此操作需使用者自行確認' }
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
await ctx.waitMs(120)
ctx.highlight(el)
el.click()
await ctx.waitMs(280)
return { action, ok: true, detail: el.textContent?.trim().slice(0, 40) ?? action.ref }
}
case 'fill': {
const el = resolveFillTarget(action, ctx)
if (!el) return { action, ok: false, detail: '找不到可填寫的欄位' }
if (el instanceof HTMLInputElement && el.type === 'password') {
return { action, ok: false, detail: '密碼欄位請使用者自行輸入' }
}
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
if ('focus' in el && typeof el.focus === 'function') el.focus()
setNativeValue(el, action.value)
ctx.highlight(el)
return { action, ok: true, detail: action.value.slice(0, 40) }
}
case 'select': {
const el = ctx.resolveRef(action.ref)
if (!el) return { action, ok: false, detail: '找不到元素' }
if (!(el instanceof HTMLSelectElement)) {
return { action, ok: false, detail: '此元素不是下拉選單' }
}
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
el.value = action.value
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
ctx.highlight(el)
return { action, ok: true, detail: action.value }
}
default:
return { action, ok: false, detail: '未知操作' }
}
}
async function runAction(
action: IslanderAction,
navigate: NavigateFunction,
snapshot: PageSnapshot,
): Promise<IslanderActionResult> {
const ctx = buildExecutorContext(navigate, snapshot)
const custom = customHandlers.get(action.type)
if (custom) {
const result = await custom(action, ctx)
if (result) return result
}
return runBuiltinAction(action, ctx)
}
type ExecuteOptions = {
actions: IslanderAction[]
navigate: NavigateFunction
snapshot?: PageSnapshot
}
function isDangerousAction(type: string): boolean {
return (ISLANDER_CONFIG.dangerousActionTypes as readonly string[]).includes(type)
}
// 對危險動作要求真人確認AI 自填的 confirm 不足以放行。
function confirmDangerousAction(type: string): boolean {
if (typeof window === 'undefined' || typeof window.confirm !== 'function') return false
return window.confirm(
`島民想替你執行「${type}」,這會實際送出 / 發布或啟動背景任務。確定要執行嗎?`,
)
}
export async function executeIslanderActions(opts: ExecuteOptions): Promise<{
results: IslanderActionResult[]
snapshotText: string
}> {
const snapshot = opts.snapshot ?? capturePageSnapshot()
const results: IslanderActionResult[] = []
for (const action of opts.actions) {
if (isDangerousAction(action.type) && !confirmDangerousAction(action.type)) {
results.push({ action, ok: false, detail: '使用者未確認,已略過此操作' })
break
}
const result = await runAction(action, opts.navigate, snapshot)
results.push(result)
if (!result.ok && action.type !== 'wait') break
}
await waitMs(200)
return {
results,
snapshotText: formatPageSnapshot(capturePageSnapshot()),
}
}