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