返回 DeepSeek-Reasonix
actions.ts
根目录 / desktop / electron / src / main / browser / actions.ts
1 import { statSync } from "node:fs";
2 import { isAbsolute } from "node:path";
3 import type { DocumentBinding, DocumentRegistry, FrameBinding } from "./documents.js";
4 import { noGrant, staleReference, takenOver } from "./errors.js";
5 import type { GuestFrame, GuestPage } from "./guestView.js";
6 import { chordEvents, parseKeySequence } from "./keys.js";
7 import { FOCUS_SCRIPT_SOURCE, IDENTITY_SCRIPT_SOURCE, scriptCall, SELECT_SCRIPT_SOURCE, type ResolvedElement, type SelectOutput } from "./pageScripts.js";
8 import { frameForRef, locateRef, resolveRef } from "./refResolver.js";
9 import { ISOLATED_WORLD, REGISTRY_KEY, runInFrame } from "./snapshot.js";
10 import type { BrowserSurfaceManager, BrowserTab } from "./surfaceManager.js";
11 import { uploadFiles } from "./upload.js";
12
13 export const ACT_SETTLE_MS = 150;
14
15 export interface ActRequest {
16 operationId: string;
17 tabId: string;
18 documentToken: string;
19 action: string;
20 ref: string;
21 text: string;
22 keys: string;
23 options: string[];
24 files: string[];
25 submit: boolean;
26 deltaX: number;
27 deltaY: number;
28 }
29
30 export interface ActResult {
31 executed: boolean;
32 outcome?: "unknown";
33 reason?: string;
34 documentToken?: string;
35 }
36
37 export interface ActionDeps {
38 surfaces: BrowserSurfaceManager;
39 documents: DocumentRegistry;
40 settleMs?: number;
41 sleep?(ms: number): Promise<void>;
42 fileExists?(path: string): boolean;
43 }
44
45 interface Point {
46 x: number;
47 y: number;
48 }
49
50 const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
51 const defaultFileExists = (path: string) => {
52 try {
53 return statSync(path).isFile();
54 } catch {
55 return false;
56 }
57 };
58
59 export class ActionExecutor {
60 private readonly sleep: (ms: number) => Promise<void>;
61 private readonly fileExists: (path: string) => boolean;
62
63 constructor(private readonly deps: ActionDeps) {
64 this.sleep = deps.sleep ?? defaultSleep;
65 this.fileExists = deps.fileExists ?? defaultFileExists;
66 }
67
68 // verify re-checks the grant at every checkpoint, so a revoke or take-over
69 // that lands while the action is still resolving cancels it before any
70 // input is dispatched; once dispatched, the receipt is honest about it.
71 async act(tab: BrowserTab, request: ActRequest, verify: () => void): Promise<ActResult> {
72 const binding = this.deps.documents.lookup(request.documentToken);
73 if (!binding || binding.tabId !== tab.id) throw staleReference("documentToken is not current for this tab");
74 this.checkpoint(tab, binding, verify);
75 const page = tab.view.page;
76 let outcome: ActResult;
77 let dispatched = false;
78 const dispatch = () => { dispatched = true; };
79 try {
80 switch (request.action) {
81 case "click":
82 outcome = await this.click(tab, binding, request, verify, dispatch);
83 break;
84 case "type":
85 outcome = await this.type(tab, binding, request, verify, dispatch);
86 break;
87 case "press":
88 outcome = await this.press(tab, binding, request, verify, dispatch);
89 break;
90 case "scroll":
91 outcome = await this.scroll(tab, binding, request, verify, dispatch);
92 break;
93 case "select":
94 outcome = await this.select(tab, binding, request, verify, dispatch, () => { dispatched = false; });
95 break;
96 case "upload":
97 outcome = await this.upload(tab, binding, request, verify, dispatch);
98 break;
99 default:
100 return { executed: false, reason: `unsupported action ${JSON.stringify(request.action)}`, documentToken: request.documentToken };
101 }
102 } catch (error) {
103 // A checkpoint failure after any dispatch cannot prove zero effects.
104 // Preserve uncertainty so the durable ledger never permits a replay.
105 if (dispatched) return { executed: false, outcome: "unknown", reason: String(error) };
106 throw error;
107 }
108 if (!outcome.executed) return { ...outcome, documentToken: request.documentToken };
109 await this.sleep(this.deps.settleMs ?? ACT_SETTLE_MS);
110 if (tab.epoch !== binding.epoch || page.isDestroyed()) return { executed: true };
111 let same = false;
112 try {
113 same = (await page.executeJavaScriptInIsolatedWorld(ISOLATED_WORLD, [{ code: scriptCall(IDENTITY_SCRIPT_SOURCE, { key: REGISTRY_KEY, docId: binding.frames[0]?.docId ?? "" }) }])) === true;
114 } catch {
115 same = false;
116 }
117 if (!same) return { executed: true };
118 const token = this.deps.documents.rotate(request.documentToken);
119 return token ? { executed: true, documentToken: token } : { executed: true };
120 }
121
122 private checkpoint(tab: BrowserTab, binding: DocumentBinding, verify: () => void): void {
123 verify();
124 if (this.deps.surfaces.get(tab.id) !== tab) throw noGrant(`browser tab ${tab.id} is closed`);
125 if (tab.mode !== "agent") throw takenOver(`tab ${tab.id} is in human mode`);
126 if (tab.epoch !== binding.epoch) throw staleReference(`tab ${tab.id} changed since the snapshot (epoch ${binding.epoch} → ${tab.epoch})`);
127 if (tab.view.page.isDestroyed()) throw staleReference(`tab ${tab.id} has no page`);
128 }
129
130 private async target(tab: BrowserTab, binding: DocumentBinding, ref: string, verify: () => void): Promise<{ ok: true; element: ResolvedElement; centre: Point; frame: GuestFrame; binding: FrameBinding } | { ok: false; reason: string }> {
131 if (ref === "") return { ok: false, reason: "this action needs a ref" };
132 const resolved = await resolveRef(tab.view.page, binding, ref, true);
133 if (!resolved.ok) return resolved;
134 this.checkpoint(tab, binding, verify);
135 const zoom = tab.view.page.getZoomFactor() || 1;
136 const { element } = resolved.value;
137 const centre = { x: Math.round((element.x + element.width / 2) * zoom), y: Math.round((element.y + element.height / 2) * zoom) };
138 return { ok: true, element, centre, frame: resolved.value.frame, binding: resolved.value.binding };
139 }
140
141 private mouseClick(tab: BrowserTab, at: Point): void {
142 const page = tab.view.page;
143 this.deps.surfaces.markAgentInput(tab);
144 page.sendInputEvent({ type: "mouseMove", x: at.x, y: at.y });
145 page.sendInputEvent({ type: "mouseDown", x: at.x, y: at.y, button: "left", clickCount: 1 });
146 page.sendInputEvent({ type: "mouseUp", x: at.x, y: at.y, button: "left", clickCount: 1 });
147 }
148
149 private sendKeys(tab: BrowserTab, keys: string): string | null {
150 let chords;
151 try {
152 chords = parseKeySequence(keys);
153 } catch (error) {
154 return error instanceof Error ? error.message : String(error);
155 }
156 if (chords.length === 0) return "no keys given";
157 this.deps.surfaces.markAgentInput(tab);
158 for (const chord of chords) {
159 for (const event of chordEvents(chord)) tab.view.page.sendInputEvent(event);
160 }
161 return null;
162 }
163
164 private async click(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> {
165 const target = await this.target(tab, binding, request.ref, verify);
166 if (!target.ok) return { executed: false, reason: target.reason };
167 if (target.element.disabled) return { executed: false, reason: "element is disabled" };
168 if (target.element.tag === "option") return { executed: false, reason: "use the select action for <option> elements" };
169 dispatch();
170 this.mouseClick(tab, target.centre);
171 return { executed: true };
172 }
173
174 private async type(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> {
175 const target = await this.target(tab, binding, request.ref, verify);
176 if (!target.ok) return { executed: false, reason: target.reason };
177 if (target.element.disabled) return { executed: false, reason: "element is disabled" };
178 if (!target.element.editable) return { executed: false, reason: "element is not editable" };
179 dispatch();
180 this.mouseClick(tab, target.centre);
181 await this.sleep(30);
182 this.checkpoint(tab, binding, verify);
183 this.deps.surfaces.markAgentInput(tab);
184 if (request.text !== "") await tab.view.page.insertText(request.text);
185 if (request.submit) {
186 this.checkpoint(tab, binding, verify);
187 const failure = this.sendKeys(tab, "Enter");
188 if (failure) return { executed: false, reason: failure };
189 }
190 return { executed: true };
191 }
192
193 private async press(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> {
194 if (request.keys.trim() === "") return { executed: false, reason: "press needs keys" };
195 // Validate before focusing: focusing itself dispatches a physical click.
196 try { parseKeySequence(request.keys); } catch (error) { return { executed: false, reason: String(error) }; }
197 if (request.ref !== "") {
198 const target = await this.target(tab, binding, request.ref, verify);
199 if (!target.ok) return { executed: false, reason: target.reason };
200 if (target.element.editable) {
201 dispatch();
202 this.mouseClick(tab, target.centre);
203 await this.sleep(30);
204 this.checkpoint(tab, binding, verify);
205 } else {
206 this.checkpoint(tab, binding, verify);
207 const focused = await runInFrame(tab.view.page, target.frame, scriptCall(FOCUS_SCRIPT_SOURCE, {
208 key: REGISTRY_KEY, snapshotId: binding.snapshotId, docId: target.binding.docId, ref: request.ref,
209 }));
210 if (focused !== true) return { executed: false, reason: "element could not be focused" };
211 this.checkpoint(tab, binding, verify);
212 }
213 }
214 dispatch();
215 const failure = this.sendKeys(tab, request.keys);
216 return failure ? { executed: false, reason: failure } : { executed: true };
217 }
218
219 // Blink negates WebMouseWheelEvent deltas when it builds the DOM WheelEvent,
220 // so the request keeps DOM semantics (positive deltaY scrolls down) and the
221 // sign flips here.
222 private async scroll(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> {
223 let at: Point;
224 if (request.ref !== "") {
225 const target = await this.target(tab, binding, request.ref, verify);
226 if (!target.ok) return { executed: false, reason: target.reason };
227 at = target.centre;
228 } else {
229 const size = await this.viewport(tab);
230 at = { x: Math.round(size.width / 2), y: Math.round(size.height / 2) };
231 this.checkpoint(tab, binding, verify);
232 }
233 if (!Number.isFinite(request.deltaX) || !Number.isFinite(request.deltaY)) return { executed: false, reason: "scroll deltas must be numbers" };
234 if (request.deltaX === 0 && request.deltaY === 0) return { executed: false, reason: "scroll deltas are both zero" };
235 this.deps.surfaces.markAgentInput(tab);
236 dispatch();
237 tab.view.page.sendInputEvent({ type: "mouseWheel", x: at.x, y: at.y, deltaX: -request.deltaX, deltaY: -request.deltaY, canScroll: true });
238 return { executed: true };
239 }
240
241 private async viewport(tab: BrowserTab): Promise<{ width: number; height: number }> {
242 const zoom = tab.view.page.getZoomFactor() || 1;
243 const raw = await tab.view.page.executeJavaScriptInIsolatedWorld(ISOLATED_WORLD, [{ code: "({ width: window.innerWidth, height: window.innerHeight })" }]);
244 const size = typeof raw === "object" && raw !== null ? (raw as { width?: unknown; height?: unknown }) : {};
245 return { width: (typeof size.width === "number" ? size.width : 0) * zoom, height: (typeof size.height === "number" ? size.height : 0) * zoom };
246 }
247
248 private async select(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void, noEffects: () => void): Promise<ActResult> {
249 if (request.ref === "") return { executed: false, reason: "select needs a ref" };
250 const page: GuestPage = tab.view.page;
251 const target = frameForRef(page, binding, request.ref);
252 const code = scriptCall(SELECT_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId: binding.snapshotId, docId: target.binding.docId, ref: request.ref, options: request.options });
253 let raw: unknown;
254 this.checkpoint(tab, binding, verify);
255 dispatch();
256 try {
257 raw = await runInFrame(page, target.frame, code);
258 } catch (error) {
259 throw staleReference(`frame of ${request.ref} cannot run scripts: ${String(error)}`);
260 }
261 const out = raw as SelectOutput | null;
262 if (!out || typeof out.ok !== "boolean") throw staleReference("select script returned nothing");
263 if (!out.ok) {
264 noEffects();
265 if (out.reason === "stale") throw staleReference(`${request.ref} predates the current document`);
266 return { executed: false, reason: out.reason };
267 }
268 return { executed: true };
269 }
270
271 private async upload(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> {
272 if (request.files.length === 0) return { executed: false, reason: "upload needs files" };
273 for (const file of request.files) {
274 if (!isAbsolute(file)) return { executed: false, reason: `file path must be absolute: ${file}` };
275 if (!this.fileExists(file)) return { executed: false, reason: `file not found: ${file}` };
276 }
277 if (request.ref === "") return { executed: false, reason: "upload needs a ref" };
278 const located = await locateRef(tab.view.page, binding, request.ref);
279 if (!located.ok) return { executed: false, reason: located.reason };
280 this.checkpoint(tab, binding, verify);
281 return uploadFiles(tab.view.page, located.value, request.files, () => this.checkpoint(tab, binding, verify), dispatch);
282 }
283 }
284
284 lines TYPESCRIPT