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()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|