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

226 lines
7.8 KiB
TypeScript
Raw Normal View History

2026-06-26 08:37:04 +00:00
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
}
2026-06-26 16:02:06 +00:00
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}」,這會實際送出 / 發布或啟動背景任務。確定要執行嗎?`,
)
}
2026-06-26 08:37:04 +00:00
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) {
2026-06-26 16:02:06 +00:00
if (isDangerousAction(action.type) && !confirmDangerousAction(action.type)) {
results.push({ action, ok: false, detail: '使用者未確認,已略過此操作' })
break
}
2026-06-26 08:37:04 +00:00
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()),
}
}