@tanzerfe/page-use 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2693 @@
1
+ import { PageAgent } from "page-agent";
2
+ import { LitElement, css, html, nothing } from "lit";
3
+ import { keyed } from "lit/directives/keyed.js";
4
+
5
+ //#region src/core/siteMap.ts
6
+ /** 去掉 `#`、前导 `/` 与查询串,得到用于比较的路径 */
7
+ function normalizePath(path) {
8
+ return path.replace(/^#/, "").replace(/^\/+/, "").split("?")[0].replace(/\/+$/, "");
9
+ }
10
+ /** 菜单 path → 页面 URL 中可见的形式:hash 模式 `#/a/b`,history 模式 `/a/b` */
11
+ function toDisplayPath(path, mode) {
12
+ const normalized = `/${normalizePath(path)}`;
13
+ return mode === "hash" ? `#${normalized}` : normalized;
14
+ }
15
+ function visibleChildren(node) {
16
+ return (node.children || []).filter((child) => !child.hidden);
17
+ }
18
+ function renderNodes(nodes, depth, mode, lines) {
19
+ const indent = " ".repeat(depth);
20
+ for (const node of nodes) {
21
+ if (node.hidden) continue;
22
+ const children = visibleChildren(node);
23
+ if (children.length) {
24
+ lines.push(`${indent}- 「${node.label}」(分组菜单,点击后展开下级)`);
25
+ renderNodes(children, depth + 1, mode, lines);
26
+ } else if (node.path !== void 0) lines.push(`${indent}- 「${node.label}」 → ${toDisplayPath(node.path, mode)}`);
27
+ else lines.push(`${indent}- 「${node.label}」`);
28
+ }
29
+ }
30
+ /** 生成菜单树文本;菜单为空时返回空串 */
31
+ function buildSiteMapText(menus, mode = "hash") {
32
+ const lines = [];
33
+ renderNodes(menus || [], 0, mode, lines);
34
+ return lines.join("\n");
35
+ }
36
+ /** 由路径反查菜单节点(用于按页提示) */
37
+ function findMenuByPath(path, menus) {
38
+ const target = normalizePath(path);
39
+ const walk = (list) => {
40
+ for (const node of list) {
41
+ if (node.path !== void 0 && normalizePath(node.path) === target) return node;
42
+ if (node.children?.length) {
43
+ const found = walk(node.children);
44
+ if (found) return found;
45
+ }
46
+ }
47
+ };
48
+ return walk(menus || []);
49
+ }
50
+
51
+ //#endregion
52
+ //#region src/core/pageInstructions.ts
53
+ /** 未提供 layoutHint 时的通用页面结构描述 */
54
+ const DEFAULT_LAYOUT_HINT = ["- 页面通常由导航菜单与内容区组成;内容区顶部若有面包屑或标题,可用来确认当前位置。", "- 分组菜单点击后展开/收起下级;叶子菜单点击后进入页面。"].join("\n");
55
+ /** 全局系统提示:站点结构 + 交互约定 + 安全边界 */
56
+ function buildSystemInstruction(options) {
57
+ const urlLabel = options.routerMode === "hash" ? "URL hash" : "URL 路径";
58
+ const siteMap = buildSiteMapText(options.getMenus(), options.routerMode);
59
+ return [
60
+ `你正在「${options.appName}」这个 Web 系统内替用户操作页面。`,
61
+ "",
62
+ "## 页面结构",
63
+ options.layoutHint?.trim() || DEFAULT_LAYOUT_HINT,
64
+ "",
65
+ ...siteMap ? [
66
+ `## 可导航的页面(菜单树,右侧为进入后的 ${urlLabel})`,
67
+ siteMap,
68
+ ""
69
+ ] : [],
70
+ "## 操作约定",
71
+ "- 到达页面只能通过点击导航菜单、面包屑、页面内按钮/链接,禁止手动改 URL。",
72
+ `- 进入页面后对照 ${urlLabel} 与页面标题/面包屑确认已到达目标,再进行下一步。`,
73
+ "- 页面内的 Tab 通过点击 Tab 标签切换;详情/编辑通常是表格行内的按钮,点击后在抽屉或弹窗中打开。",
74
+ "- 下拉选择、日期选择若为自定义组件而非原生 select:先点击该输入框,再点击弹出层中的目标选项文字。",
75
+ "- 数据加载需要时间;点击后若内容未变化,先使用 wait 等待 1 到 2 秒再重新观察。",
76
+ "",
77
+ "## 安全边界",
78
+ "- 未经用户明确要求,不得执行删除、提交、审批、发布、批量修改等不可逆操作;不确定时用 ask_user 询问。",
79
+ "- 任务完成后调用 done,用一句话说明当前所在页面及已完成的动作;无法完成时说明卡在哪一步。"
80
+ ].join("\n");
81
+ }
82
+ /**
83
+ * 按页提示:page-agent 每步调用一次。
84
+ * 路由名命中 routeHints 优先,其次按路径命中;返回 null 表示不追加。
85
+ */
86
+ function buildPageInstruction(options) {
87
+ const path = options.getCurrentPath();
88
+ const parts = [];
89
+ const menu = findMenuByPath(path, options.getMenus());
90
+ if (menu) parts.push(`当前页面:「${menu.label}」(${toDisplayPath(path, options.routerMode)})`);
91
+ let routeName;
92
+ try {
93
+ routeName = options.resolveRouteName?.(path);
94
+ } catch {
95
+ routeName = void 0;
96
+ }
97
+ const hint = routeName && options.routeHints[routeName] || options.routeHints[normalizePath(path)];
98
+ if (hint) parts.push(hint);
99
+ return parts.length ? parts.join("\n") : null;
100
+ }
101
+
102
+ //#endregion
103
+ //#region src/core/createPageUseAgent.ts
104
+ /**
105
+ * page-agent 实例工厂(由 core/service 统一调度)。
106
+ * - LLM:浏览器请求 OpenAI 兼容端点,参数来自宿主选项
107
+ * - 站点知识:菜单树写入 system 提示,按页提示按当前路径动态注入
108
+ * - 任务开始前按设置执行宿主的 beforeTask(如展开折叠的侧栏)
109
+ */
110
+ /** DOM 文本超过该长度时截断,避免大表格页把上下文撑爆 */
111
+ const PAGE_CONTENT_MAX_CHARS = 6e4;
112
+ /** 每步间隔(秒):留给路由切换与接口加载的时间 */
113
+ const STEP_DELAY_SECONDS = .6;
114
+ /** 生成会话 ID;一次 execute 视为一个会话,任务开始时刷新 */
115
+ function newSessionId() {
116
+ return typeof crypto !== "undefined" && crypto.randomUUID?.() || `page-use-${Date.now()}`;
117
+ }
118
+ function createPageUseAgent({ options, enableMask, shouldRunBeforeTask, onTaskFinished }) {
119
+ const { llm } = options;
120
+ if (!llm.baseURL) throw new Error("[PageUse] 未配置 llm.baseURL");
121
+ let sessionId = newSessionId();
122
+ /**
123
+ * 注入会话头(OpenCode Go 缺少 x-opencode-session 会返回 400)。
124
+ * User-Agent 属浏览器禁止修改的头,需要时由代理补充。
125
+ */
126
+ const customFetch = llm.sessionHeader ? (input, init) => {
127
+ const headers = new Headers(init?.headers);
128
+ headers.set(llm.sessionHeader, sessionId);
129
+ return fetch(input, {
130
+ ...init,
131
+ headers
132
+ });
133
+ } : void 0;
134
+ const extraBody = llm.extraBody;
135
+ const hostBeforeTask = options.beforeTask;
136
+ return new PageAgent({
137
+ baseURL: llm.baseURL,
138
+ apiKey: llm.apiKey || void 0,
139
+ model: llm.model,
140
+ language: "zh-CN",
141
+ maxSteps: llm.maxSteps,
142
+ disableNamedToolChoice: llm.disableNamedToolChoice,
143
+ customFetch,
144
+ transformRequestBody: Object.keys(extraBody).length ? (body) => ({
145
+ ...body,
146
+ ...extraBody
147
+ }) : void 0,
148
+ stepDelay: STEP_DELAY_SECONDS,
149
+ promptForNextTask: false,
150
+ enableMask,
151
+ experimentalScriptExecutionTool: false,
152
+ instructions: {
153
+ system: buildSystemInstruction(options),
154
+ getPageInstructions: () => buildPageInstruction(options)
155
+ },
156
+ transformPageContent: (content) => {
157
+ if (content.length <= PAGE_CONTENT_MAX_CHARS) return content;
158
+ return `${content.slice(0, PAGE_CONTENT_MAX_CHARS)}\n...(页面内容过长已截断,可滚动后再观察)`;
159
+ },
160
+ ...options.pageAgentOptions,
161
+ onBeforeTask: async (...args) => {
162
+ sessionId = newSessionId();
163
+ if (hostBeforeTask && shouldRunBeforeTask()) try {
164
+ await hostBeforeTask();
165
+ } catch (err) {
166
+ console.warn("[PageUse] beforeTask 执行异常", err);
167
+ }
168
+ await options.pageAgentOptions.onBeforeTask?.(...args);
169
+ },
170
+ onAfterTask: async (...args) => {
171
+ onTaskFinished?.(args[1]);
172
+ await options.pageAgentOptions.onAfterTask?.(...args);
173
+ }
174
+ });
175
+ }
176
+
177
+ //#endregion
178
+ //#region src/core/options.ts
179
+ /** 默认模型,仅为示例取值;接入其它端点时应通过 llm.model 显式指定支持 function calling 的模型 */
180
+ const DEFAULT_MODEL = "deepseek-chat";
181
+ /** 单次任务最大步数;每步一次 LLM 调用,防止死循环消耗 token */
182
+ const DEFAULT_MAX_STEPS = 20;
183
+ /** 默认会话头:OpenCode Go 要求每个会话带稳定的 x-opencode-session 用于路由与提示缓存 */
184
+ const DEFAULT_SESSION_HEADER = "x-opencode-session";
185
+ /** localStorage / sessionStorage 键前缀,可通过 storageKeyPrefix 覆盖 */
186
+ const DEFAULT_STORAGE_KEY_PREFIX = "tz-page-use";
187
+ /** 相对路径补齐为当前 origin 的绝对地址并去掉末尾 /;无法解析时返回空串 */
188
+ function toAbsoluteUrl(input) {
189
+ const base = typeof window !== "undefined" ? window.location.origin : void 0;
190
+ try {
191
+ return new URL(input, base).toString().replace(/\/+$/, "");
192
+ } catch {
193
+ return "";
194
+ }
195
+ }
196
+ /** 按路由模式从 location 读取当前路径 */
197
+ function readLocationPath(mode) {
198
+ if (typeof window === "undefined") return "/";
199
+ if (mode === "hash") return window.location.hash.replace(/^#/, "") || "/";
200
+ return `${window.location.pathname}${window.location.search}`;
201
+ }
202
+ function resolveSessionHeader(value) {
203
+ if (value === false) return "";
204
+ return (value ?? "").trim() || "x-opencode-session";
205
+ }
206
+ function normalizeOptions(options) {
207
+ const { llm } = options;
208
+ const rawBase = (llm.baseURL || "").trim();
209
+ const routerMode = options.routerMode ?? "hash";
210
+ return {
211
+ llm: {
212
+ baseURL: rawBase ? toAbsoluteUrl(rawBase) : "",
213
+ apiKey: (llm.apiKey || "").trim(),
214
+ model: (llm.model || "").trim() || "deepseek-chat",
215
+ maxSteps: llm.maxSteps && llm.maxSteps > 0 ? llm.maxSteps : 20,
216
+ disableNamedToolChoice: Boolean(llm.disableNamedToolChoice),
217
+ extraBody: llm.extraBody && typeof llm.extraBody === "object" ? llm.extraBody : {},
218
+ sessionHeader: resolveSessionHeader(llm.sessionHeader)
219
+ },
220
+ appName: options.appName,
221
+ getMenus: options.getMenus ?? (() => void 0),
222
+ routerMode,
223
+ getCurrentPath: options.getCurrentPath ?? (() => readLocationPath(routerMode)),
224
+ resolveRouteName: options.resolveRouteName,
225
+ routeHints: options.routeHints ?? {},
226
+ layoutHint: options.layoutHint,
227
+ beforeTask: options.beforeTask,
228
+ beforeTaskLabel: options.beforeTaskLabel || "任务开始前执行宿主预处理",
229
+ storageKeyPrefix: options.storageKeyPrefix || "tz-page-use",
230
+ pageAgentOptions: options.pageAgentOptions ?? {},
231
+ exposeGlobal: options.exposeGlobal !== false
232
+ };
233
+ }
234
+ /** URL 查询参数:?pageUseDebug=1 打开调试面板,?pageUseDebug=0 关闭;结果记入 sessionStorage */
235
+ const LAUNCHER_QUERY_KEY = "pageUseDebug";
236
+ /** 从 location 读取调试开关(hash 路由的查询参数在 # 之后,两处都查) */
237
+ function readLauncherQuery() {
238
+ const fromSearch = new URLSearchParams(window.location.search).get(LAUNCHER_QUERY_KEY);
239
+ if (fromSearch !== null) return fromSearch;
240
+ const hashQuery = window.location.hash.split("?")[1];
241
+ return hashQuery ? new URLSearchParams(hashQuery).get(LAUNCHER_QUERY_KEY) : null;
242
+ }
243
+ /**
244
+ * 调试面板是否显示。优先级:URL 参数(并记入本会话)> 会话记录 > fallback。
245
+ * fallback 由宿主决定(例如「开发环境显示,生产环境隐藏」)。
246
+ */
247
+ function readLauncherSwitch(fallback, storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX) {
248
+ if (typeof window === "undefined") return fallback;
249
+ const sessionKey = `${storageKeyPrefix}-debug`;
250
+ try {
251
+ const query = readLauncherQuery();
252
+ if (query !== null) {
253
+ const enabled = query === "1" || query === "true";
254
+ window.sessionStorage.setItem(sessionKey, enabled ? "1" : "0");
255
+ return enabled;
256
+ }
257
+ const session = window.sessionStorage.getItem(sessionKey);
258
+ if (session !== null) return session === "1";
259
+ } catch {}
260
+ return fallback;
261
+ }
262
+
263
+ //#endregion
264
+ //#region src/core/store.ts
265
+ function createStore(initial) {
266
+ let state = initial;
267
+ const listeners = /* @__PURE__ */ new Set();
268
+ const notify = () => {
269
+ for (const listener of listeners) try {
270
+ listener(state);
271
+ } catch (err) {
272
+ console.warn("[PageUse] store 订阅回调异常", err);
273
+ }
274
+ };
275
+ return {
276
+ get: () => state,
277
+ set(patch) {
278
+ const partial = typeof patch === "function" ? patch(state) : patch;
279
+ if (Object.keys(partial).every((key) => Object.is(state[key], partial[key]))) return;
280
+ state = {
281
+ ...state,
282
+ ...partial
283
+ };
284
+ notify();
285
+ },
286
+ replace(next) {
287
+ if (Object.is(next, state)) return;
288
+ state = next;
289
+ notify();
290
+ },
291
+ subscribe(listener) {
292
+ listeners.add(listener);
293
+ return () => {
294
+ listeners.delete(listener);
295
+ };
296
+ }
297
+ };
298
+ }
299
+
300
+ //#endregion
301
+ //#region src/core/history.ts
302
+ /**
303
+ * Page Use 指令历史(localStorage,最多 30 条,最新在前)。
304
+ * 用于快速重放上次指令,以及查看任务结果。存储键为 `${storageKeyPrefix}-history`。
305
+ */
306
+ const MAX_ENTRIES = 30;
307
+ function storageKey$1(prefix) {
308
+ return `${prefix}-history`;
309
+ }
310
+ function load$1(prefix) {
311
+ try {
312
+ const raw = window.localStorage.getItem(storageKey$1(prefix));
313
+ const parsed = raw ? JSON.parse(raw) : [];
314
+ return Array.isArray(parsed) ? parsed : [];
315
+ } catch {
316
+ return [];
317
+ }
318
+ }
319
+ let prefix$1 = DEFAULT_STORAGE_KEY_PREFIX;
320
+ /** 懒创建:避免模块加载即访问 window(SSR / 测试环境) */
321
+ let store$2 = null;
322
+ function ensureStore$1() {
323
+ if (!store$2) store$2 = createStore({ entries: load$1(prefix$1) });
324
+ return store$2;
325
+ }
326
+ function persist(entries) {
327
+ ensureStore$1().set({ entries });
328
+ try {
329
+ window.localStorage.setItem(storageKey$1(prefix$1), JSON.stringify(entries));
330
+ } catch {}
331
+ }
332
+ /** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
333
+ function setHistoryStoragePrefix(next) {
334
+ if (next === prefix$1 && store$2) return;
335
+ prefix$1 = next;
336
+ ensureStore$1().replace({ entries: load$1(prefix$1) });
337
+ }
338
+ const pageUseHistory = {
339
+ store: {
340
+ get: () => ensureStore$1().get(),
341
+ subscribe: (listener) => ensureStore$1().subscribe(listener)
342
+ },
343
+ entries() {
344
+ return ensureStore$1().get().entries;
345
+ },
346
+ add(entry) {
347
+ persist([{
348
+ id: `${entry.at}-${Math.random().toString(36).slice(2, 8)}`,
349
+ ...entry
350
+ }, ...ensureStore$1().get().entries].slice(0, MAX_ENTRIES));
351
+ },
352
+ remove(id) {
353
+ persist(ensureStore$1().get().entries.filter((item) => item.id !== id));
354
+ },
355
+ clear() {
356
+ persist([]);
357
+ }
358
+ };
359
+
360
+ //#endregion
361
+ //#region src/core/maskTheme.ts
362
+ /**
363
+ * 运行期蒙层与模拟光标的定制主题(覆盖 page-agent SimulatorMask 的默认视觉)。
364
+ *
365
+ * page-agent 的 CSS Module 类名带哈希(如 `_cursor_1dgwb_2`),随版本变化,
366
+ * 因此这里只依赖稳定的结构:
367
+ * - 蒙层根节点 id:`page-agent-runtime_simulator-mask`
368
+ * - 子节点顺序:ai-motion 光晕层在前(初始化失败时不存在),光标节点恒为最后一个子节点
369
+ * - 光标子节点顺序:1 点击波纹 / 2 箭头填充 / 3 箭头描边
370
+ * - 点击态类名包含 `clicking` 子串
371
+ * 升级 page-agent 后若光标样式失效,先核对 SimulatorMask 的 #createCursor 结构。
372
+ */
373
+ /** 蒙层根节点 id(@page-agent/page-controller 内部常量) */
374
+ const SIMULATOR_MASK_ID = "page-agent-runtime_simulator-mask";
375
+ /** 主题色:主色 #0879ff 同系的蓝 → 青 → 靛渐变 */
376
+ const PAGE_USE_MASK_COLORS = {
377
+ primary: "#0879ff",
378
+ cyan: "#36cfc9",
379
+ indigo: "#597ef7",
380
+ /** 误触拦截时的警示色 */
381
+ warning: "#fa8c16"
382
+ };
383
+ const CURSOR_ARROW_MASK = `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><path d='M 15 42 L 15 36.99 Q 15 31.99 23.7 31.99 L 28.05 31.99 Q 32.41 31.99 32.41 21.99 L 32.41 17 Q 32.41 12 41.09 16.95 L 76.31 37.05 Q 85 42 76.31 46.95 L 41.09 67.05 Q 32.41 72 32.41 62.01 L 32.41 57.01 Q 32.41 52.01 23.7 52.01 L 19.35 52.01 Q 15 52.01 15 47.01 Z' fill='#000'/></svg>`)}")`;
384
+ const MASK = `#${SIMULATOR_MASK_ID}`;
385
+ /** 光标节点:蒙层的最后一个子节点 */
386
+ const CURSOR = `${MASK} > :last-child`;
387
+ const { primary, cyan, indigo } = PAGE_USE_MASK_COLORS;
388
+ const PAGE_USE_MASK_THEME_CSS = `
389
+ /* 隐藏 ai-motion 默认光晕(光标之外的子节点),光晕改由 <tz-page-use-glow> 渲染 */
390
+ ${MASK} > :not(:last-child) { display: none !important; }
391
+ /* 蒙层本身保持透明,仅负责拦截点击;真实鼠标由 wait 转圈改为禁止符号,明确「此时不可操作」而非「页面卡死」 */
392
+ ${MASK} { cursor: not-allowed !important; }
393
+
394
+ /* 光标整体外发光 */
395
+ ${CURSOR} {
396
+ filter: drop-shadow(0 0 6px ${primary}8c) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
397
+ }
398
+ /* 光标尖端(节点原点)的呼吸光圈 */
399
+ ${CURSOR}::before {
400
+ content: '';
401
+ position: absolute;
402
+ left: -16px;
403
+ top: -16px;
404
+ width: 32px;
405
+ height: 32px;
406
+ border-radius: 50%;
407
+ background: radial-gradient(circle, ${cyan}66 0%, ${primary}26 55%, transparent 72%);
408
+ animation: page-use-cursor-halo 1.6s ease-in-out infinite;
409
+ pointer-events: none;
410
+ }
411
+ /* 箭头描边:主题渐变 */
412
+ ${CURSOR} > :nth-child(3) {
413
+ background: linear-gradient(45deg, ${primary}, ${cyan}) !important;
414
+ }
415
+ /* 箭头填充:用同形遮罩替换原白色 SVG 背景,改为浅色渐变 */
416
+ ${CURSOR} > :nth-child(2) {
417
+ background: linear-gradient(135deg, #ffffff 30%, #e6f4ff) !important;
418
+ -webkit-mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
419
+ mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
420
+ }
421
+ /* 点击波纹:外环改主题色 */
422
+ ${CURSOR} > :nth-child(1)::after {
423
+ border-color: ${cyan} !important;
424
+ border-width: 3px !important;
425
+ }
426
+ /* 点击波纹:追加一层实心扩散,增强点击反馈 */
427
+ ${CURSOR} > :nth-child(1)::before {
428
+ content: '';
429
+ position: absolute;
430
+ inset: 25%;
431
+ border-radius: 50%;
432
+ background: radial-gradient(circle, ${indigo}80, transparent 70%);
433
+ opacity: 0;
434
+ }
435
+ ${CURSOR}[class*='clicking'] > :nth-child(1)::before {
436
+ animation: page-use-cursor-press 420ms ease-out forwards;
437
+ }
438
+
439
+ @keyframes page-use-cursor-halo {
440
+ 0%, 100% { transform: scale(0.85); opacity: 0.7; }
441
+ 50% { transform: scale(1.25); opacity: 1; }
442
+ }
443
+ @keyframes page-use-cursor-press {
444
+ 0% { transform: scale(0.4); opacity: 1; }
445
+ 100% { transform: scale(2.4); opacity: 0; }
446
+ }
447
+ @media (prefers-reduced-motion: reduce) {
448
+ ${CURSOR}::before { animation: none; }
449
+ }
450
+ `;
451
+
452
+ //#endregion
453
+ //#region src/core/settings.ts
454
+ /**
455
+ * Page Use 本地设置(localStorage 持久化,单例)。
456
+ * 含 page-agent 内置面板/元素编号高亮开关、蒙层与光晕、调试入口拖拽位置。
457
+ * 存储键为 `${storageKeyPrefix}-settings`。
458
+ */
459
+ /** page-agent 元素高亮容器 id(page-controller 内部常量,随版本可能变化) */
460
+ const HIGHLIGHT_CONTAINER_ID = "playwright-highlight-container";
461
+ const HIGHLIGHT_STYLE_ID = "page-use-hide-highlights";
462
+ /** page-agent 内置面板根节点 id(@page-agent/ui 内部常量) */
463
+ const BUILTIN_PANEL_ID = "page-agent-runtime_agent-panel";
464
+ const BUILTIN_PANEL_STYLE_ID = "page-use-hide-builtin-panel";
465
+ const MASK_THEME_STYLE_ID = "page-use-mask-theme";
466
+ const DEFAULT_PAGE_USE_SETTINGS = {
467
+ showBuiltinPanel: false,
468
+ showHighlights: false,
469
+ showStepDetails: true,
470
+ enableBeforeTask: true,
471
+ enableMask: true,
472
+ customMaskEffect: true,
473
+ fabPos: null
474
+ };
475
+ function storageKey(prefix) {
476
+ return `${prefix}-settings`;
477
+ }
478
+ function load(prefix) {
479
+ try {
480
+ const raw = window.localStorage.getItem(storageKey(prefix));
481
+ if (!raw) return { ...DEFAULT_PAGE_USE_SETTINGS };
482
+ return {
483
+ ...DEFAULT_PAGE_USE_SETTINGS,
484
+ ...JSON.parse(raw)
485
+ };
486
+ } catch {
487
+ return { ...DEFAULT_PAGE_USE_SETTINGS };
488
+ }
489
+ }
490
+ function save(prefix, settings) {
491
+ try {
492
+ window.localStorage.setItem(storageKey(prefix), JSON.stringify(settings));
493
+ } catch {}
494
+ }
495
+ /** 按开关注入/移除一段覆盖样式(用于压制 page-agent 自带的 DOM,这些节点位于宿主 document 中) */
496
+ function toggleOverrideStyle(styleId, css, show) {
497
+ const existing = document.getElementById(styleId);
498
+ if (show) {
499
+ existing?.remove();
500
+ return;
501
+ }
502
+ if (existing) return;
503
+ const style = document.createElement("style");
504
+ style.id = styleId;
505
+ style.textContent = css;
506
+ document.head.appendChild(style);
507
+ }
508
+ /**
509
+ * 把设置同步到页面:
510
+ * - 高亮:page-controller 的 doHighlightElements 内部写死为 true,只能用样式覆盖其高亮容器
511
+ * - 内置面板:Panel 在 statuschange 到 running 时会自行 show(),因此用 !important 样式压制
512
+ * - 蒙层主题:注入覆盖样式;关闭时移除,恢复 page-agent 默认光晕与光标
513
+ */
514
+ function applyToDocument(settings, prev) {
515
+ if (typeof document === "undefined") return;
516
+ if (!prev || prev.showHighlights !== settings.showHighlights) toggleOverrideStyle(HIGHLIGHT_STYLE_ID, `#${HIGHLIGHT_CONTAINER_ID}{display:none !important;}`, settings.showHighlights);
517
+ if (!prev || prev.showBuiltinPanel !== settings.showBuiltinPanel) toggleOverrideStyle(BUILTIN_PANEL_STYLE_ID, `#${BUILTIN_PANEL_ID}{display:none !important;}`, settings.showBuiltinPanel);
518
+ if (!prev || prev.customMaskEffect !== settings.customMaskEffect) toggleOverrideStyle(MASK_THEME_STYLE_ID, PAGE_USE_MASK_THEME_CSS, !settings.customMaskEffect);
519
+ }
520
+ let prefix = DEFAULT_STORAGE_KEY_PREFIX;
521
+ let store$1 = null;
522
+ /** 懒创建:首次访问时读取存储并把设置应用到页面 */
523
+ function ensureStore() {
524
+ if (store$1) return store$1;
525
+ const created = createStore(load(prefix));
526
+ let prev = null;
527
+ const sync = (next) => {
528
+ applyToDocument(next, prev);
529
+ prev = next;
530
+ };
531
+ sync(created.get());
532
+ created.subscribe((next) => {
533
+ save(prefix, next);
534
+ sync(next);
535
+ });
536
+ store$1 = created;
537
+ return created;
538
+ }
539
+ /** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
540
+ function setSettingsStoragePrefix(next) {
541
+ if (next === prefix && store$1) return;
542
+ prefix = next;
543
+ if (store$1) store$1.replace(load(prefix));
544
+ else ensureStore();
545
+ }
546
+ const pageUseSettings = {
547
+ store: {
548
+ get: () => ensureStore().get(),
549
+ subscribe: (listener) => ensureStore().subscribe(listener)
550
+ },
551
+ get() {
552
+ return ensureStore().get();
553
+ },
554
+ update(patch) {
555
+ ensureStore().set(patch);
556
+ },
557
+ /** 把浮动入口(及随之定位的面板)恢复到默认角落位置 */
558
+ resetPositions() {
559
+ ensureStore().set({ fabPos: null });
560
+ }
561
+ };
562
+
563
+ //#endregion
564
+ //#region src/core/stepLog.ts
565
+ function summarizeActionInput(name, input) {
566
+ const record = input && typeof input === "object" ? input : {};
567
+ switch (name) {
568
+ case "click_element_by_index": return `点击元素 [${record.index}]`;
569
+ case "input_text": return `在 [${record.index}] 输入「${record.text}」`;
570
+ case "select_dropdown_option": return `选择 [${record.index}] 的「${record.text}」`;
571
+ case "scroll": return `${record.down === false ? "向上" : "向下"}滚动`;
572
+ case "scroll_horizontally": return `${record.right === false ? "向左" : "向右"}滚动`;
573
+ case "wait": return `等待 ${record.seconds ?? 1} 秒`;
574
+ case "done": return record.success === false ? "结束任务(未达成)" : "结束任务";
575
+ case "ask_user": return "向用户提问";
576
+ default: return name;
577
+ }
578
+ }
579
+ /** 工具返回文本约定:page-agent 内置工具成功以 ✅ 开头,失败以 ❌ 开头 */
580
+ function isFailedOutput(output) {
581
+ return output.startsWith("❌") || /失败|error|failed/i.test(output);
582
+ }
583
+ function toStepLogs(history) {
584
+ const logs = [];
585
+ for (const event of history) if (event.type === "step") logs.push({
586
+ index: event.stepIndex + 1,
587
+ tool: event.action.name,
588
+ evaluation: event.reflection?.evaluation_previous_goal || "",
589
+ goal: event.reflection?.next_goal || "",
590
+ action: summarizeActionInput(event.action.name, event.action.input),
591
+ output: event.action.output,
592
+ failed: isFailedOutput(event.action.output || "")
593
+ });
594
+ else if (event.type === "error") logs.push({
595
+ index: logs.length + 1,
596
+ tool: "error",
597
+ evaluation: "",
598
+ goal: "",
599
+ action: "执行出错",
600
+ output: event.message,
601
+ failed: true
602
+ });
603
+ else if (event.type === "retry") logs.push({
604
+ index: logs.length + 1,
605
+ tool: "retry",
606
+ evaluation: "",
607
+ goal: "",
608
+ action: `重试 ${event.attempt}/${event.maxAttempts}`,
609
+ output: event.message || "",
610
+ failed: false
611
+ });
612
+ return logs;
613
+ }
614
+ function sumTokens(history) {
615
+ let total = 0;
616
+ for (const event of history) if (event.type === "step") total += event.usage?.totalTokens || 0;
617
+ return total;
618
+ }
619
+ function describeActivity(activity) {
620
+ switch (activity.type) {
621
+ case "thinking": return "正在分析页面…";
622
+ case "executing": return `正在${summarizeActionInput(activity.tool, activity.input)}`;
623
+ case "executed": return `已${summarizeActionInput(activity.tool, activity.input)}`;
624
+ case "retrying": return `模型响应异常,重试 ${activity.attempt}/${activity.maxAttempts}`;
625
+ case "error": return `错误:${activity.message}`;
626
+ default: return "";
627
+ }
628
+ }
629
+
630
+ //#endregion
631
+ //#region src/core/service.ts
632
+ /** 无人可答时回给模型的固定答复,引导其基于已有信息决策或以未达成结束 */
633
+ const NO_ANSWER_REPLY = "用户当前无法补充信息,请根据页面已有信息自行判断;无法继续时调用 done 并说明原因。";
634
+ const store = createStore({
635
+ status: "idle",
636
+ activityText: "",
637
+ currentTask: "",
638
+ currentTaskId: "",
639
+ currentSource: "",
640
+ steps: [],
641
+ lastResult: null,
642
+ errorMessage: "",
643
+ pendingQuestion: null,
644
+ totalTokens: 0,
645
+ startedAt: 0,
646
+ finishedAt: 0,
647
+ nowTick: 0,
648
+ maskEnabled: false,
649
+ ready: false
650
+ });
651
+ /** 只读状态容器:UI 层订阅后自行接入响应式 */
652
+ const pageUseStore = {
653
+ get: store.get,
654
+ subscribe: store.subscribe
655
+ };
656
+ function isRunning(state = store.get()) {
657
+ return state.status === "running";
658
+ }
659
+ /** 任务耗时(秒):运行中随 nowTick 每秒刷新,结束后固定 */
660
+ function getElapsedSeconds(state = store.get()) {
661
+ if (!state.startedAt) return 0;
662
+ const end = state.finishedAt || state.nowTick || Date.now();
663
+ return Math.max(0, Math.round((end - state.startedAt) / 1e3));
664
+ }
665
+ let config = null;
666
+ /** 是否曾经配置过:用于区分 NOT_CONFIGURED(从未初始化)与 NOT_READY(已销毁) */
667
+ let everConfigured = false;
668
+ let agent = null;
669
+ /** 创建实例时的蒙层设置;与当前设置不一致时须重建 */
670
+ let agentMaskFlag = null;
671
+ /** 已挂载且可作答的调试面板数量;>0 时未指定 onAskUser 的提问交给面板 */
672
+ let questionUiCount = 0;
673
+ /** 当前任务的调用方选项(提问回调、进度回调) */
674
+ let activeOptions = null;
675
+ const listeners = /* @__PURE__ */ new Set();
676
+ let tickTimer = null;
677
+ function startTick() {
678
+ stopTick();
679
+ store.set({ nowTick: Date.now() });
680
+ tickTimer = setInterval(() => store.set({ nowTick: Date.now() }), 1e3);
681
+ }
682
+ function stopTick() {
683
+ if (tickTimer !== null) {
684
+ clearInterval(tickTimer);
685
+ tickTimer = null;
686
+ }
687
+ }
688
+ /** 同时投递给本任务调用方与全局订阅者;回调异常不影响任务执行 */
689
+ function emit(event) {
690
+ try {
691
+ activeOptions?.onProgress?.(event);
692
+ } catch (err) {
693
+ console.warn("[PageUse] onProgress 回调异常", err);
694
+ }
695
+ for (const listener of listeners) try {
696
+ listener(event);
697
+ } catch (err) {
698
+ console.warn("[PageUse] 订阅回调异常", err);
699
+ }
700
+ }
701
+ function bindAgentEvents(target) {
702
+ target.addEventListener("statuschange", () => {
703
+ store.set({ status: target.status });
704
+ });
705
+ target.addEventListener("activity", (event) => {
706
+ const activity = event.detail;
707
+ if (!activity) return;
708
+ const text = describeActivity(activity);
709
+ store.set({ activityText: text });
710
+ if (text) emit({
711
+ type: "activity",
712
+ taskId: store.get().currentTaskId,
713
+ text
714
+ });
715
+ });
716
+ target.addEventListener("historychange", () => {
717
+ const history = target.history;
718
+ const next = toStepLogs(history);
719
+ const { steps, currentTaskId } = store.get();
720
+ for (const step of next.slice(steps.length)) emit({
721
+ type: "step",
722
+ taskId: currentTaskId,
723
+ step
724
+ });
725
+ store.set({
726
+ steps: next,
727
+ totalTokens: sumTokens(history)
728
+ });
729
+ });
730
+ }
731
+ /** 模型提问的分发:调用方回调 → 调试面板 → 固定答复 */
732
+ function handleAskUser(question, signal) {
733
+ emit({
734
+ type: "question",
735
+ taskId: store.get().currentTaskId,
736
+ question
737
+ });
738
+ const callerHandler = activeOptions?.onAskUser;
739
+ if (callerHandler) return callerHandler(question);
740
+ if (questionUiCount <= 0) return Promise.resolve(NO_ANSWER_REPLY);
741
+ return new Promise((resolve, reject) => {
742
+ store.set({ pendingQuestion: {
743
+ question,
744
+ resolve: (answer) => {
745
+ store.set({ pendingQuestion: null });
746
+ resolve(answer);
747
+ }
748
+ } });
749
+ signal?.addEventListener("abort", () => {
750
+ store.set({ pendingQuestion: null });
751
+ reject(signal.reason);
752
+ }, { once: true });
753
+ });
754
+ }
755
+ /** 懒创建单例;蒙层设置变化时重建(enableMask 属构造期选项) */
756
+ function ensureAgent(options) {
757
+ const settings = pageUseSettings.get();
758
+ if (agent && !agent.disposed && agentMaskFlag === settings.enableMask) return agent;
759
+ if (agent && !agent.disposed) agent.dispose();
760
+ const created = createPageUseAgent({
761
+ options,
762
+ enableMask: settings.enableMask,
763
+ shouldRunBeforeTask: () => pageUseSettings.get().enableBeforeTask
764
+ });
765
+ bindAgentEvents(created);
766
+ created.onAskUser = (question, askOptions) => handleAskUser(question, askOptions?.signal);
767
+ agent = created;
768
+ agentMaskFlag = settings.enableMask;
769
+ store.set({ maskEnabled: settings.enableMask });
770
+ return created;
771
+ }
772
+ function newTaskId() {
773
+ return typeof crypto !== "undefined" && crypto.randomUUID?.() || `task-${Date.now()}`;
774
+ }
775
+ /** 未能开始的任务:直接返回结果,不改动当前面板状态 */
776
+ function rejectedResult(task, source, summary, errorCode, status = "error") {
777
+ return {
778
+ taskId: "",
779
+ task,
780
+ source,
781
+ status,
782
+ success: false,
783
+ summary,
784
+ errorCode,
785
+ steps: [],
786
+ tokens: 0,
787
+ durationMs: 0
788
+ };
789
+ }
790
+ function resolveFinalStatus(success) {
791
+ const final = agent?.status;
792
+ if (final === "stopped") return "stopped";
793
+ if (final === "error") return "error";
794
+ return success ? "completed" : "failed";
795
+ }
796
+ /**
797
+ * 写入配置(createPageUse 调用)。重复配置时释放旧 agent,下次任务按新配置重建。
798
+ * 返回解绑函数:清除配置并释放 agent。
799
+ */
800
+ function configurePageUse(options) {
801
+ if (config) disposeAgent();
802
+ config = options;
803
+ everConfigured = true;
804
+ setSettingsStoragePrefix(options.storageKeyPrefix);
805
+ setHistoryStoragePrefix(options.storageKeyPrefix);
806
+ store.set({ ready: Boolean(options.llm.baseURL) });
807
+ return () => {
808
+ if (config !== options) return;
809
+ config = null;
810
+ disposeAgent();
811
+ store.set({ ready: false });
812
+ };
813
+ }
814
+ /** 已调用 createPageUse 且配置了端点 */
815
+ function isPageUseConfigured() {
816
+ return Boolean(config?.llm.baseURL);
817
+ }
818
+ /** 当前生效的规范化配置;未配置时为 null */
819
+ function getPageUseConfig() {
820
+ return config;
821
+ }
822
+ /** 调试面板挂载时登记为提问作答方;返回注销函数 */
823
+ function registerQuestionUi() {
824
+ questionUiCount += 1;
825
+ return () => {
826
+ questionUiCount = Math.max(0, questionUiCount - 1);
827
+ };
828
+ }
829
+ /**
830
+ * 下达一条页面操作指令,任务结束后 resolve 结果;不会 reject。
831
+ * 未能开始(未配置 / 已销毁 / 忙 / 空指令)时返回 status='error' 且带 errorCode。
832
+ */
833
+ async function runTask(task, options = {}) {
834
+ const normalized = task.trim();
835
+ const source = options.source || "unknown";
836
+ if (!normalized) return rejectedResult(normalized, source, "指令为空", "EMPTY_TASK");
837
+ if (!config) return everConfigured ? rejectedResult(normalized, source, "页面操作能力已销毁", "NOT_READY") : rejectedResult(normalized, source, "页面操作能力未初始化(未调用 createPageUse)", "NOT_CONFIGURED");
838
+ if (!config.llm.baseURL) return rejectedResult(normalized, source, "页面操作能力未配置模型端点", "NOT_CONFIGURED");
839
+ if (isRunning() || activeOptions) return rejectedResult(normalized, source, `已有任务执行中:${store.get().currentTask}`, "BUSY");
840
+ if (options.signal?.aborted) return rejectedResult(normalized, source, "任务已取消", void 0, "stopped");
841
+ const taskId = newTaskId();
842
+ const startedAt = Date.now();
843
+ activeOptions = options;
844
+ store.set({
845
+ errorMessage: "",
846
+ activityText: "",
847
+ steps: [],
848
+ lastResult: null,
849
+ pendingQuestion: null,
850
+ totalTokens: 0,
851
+ currentTask: normalized,
852
+ currentTaskId: taskId,
853
+ currentSource: source,
854
+ startedAt,
855
+ finishedAt: 0
856
+ });
857
+ startTick();
858
+ emit({
859
+ type: "started",
860
+ taskId,
861
+ task: normalized,
862
+ source
863
+ });
864
+ const onAbort = () => void stopTask();
865
+ options.signal?.addEventListener("abort", onAbort, { once: true });
866
+ let result;
867
+ try {
868
+ const execution = await ensureAgent(config).execute(normalized);
869
+ const final = resolveFinalStatus(execution.success);
870
+ const { steps, totalTokens } = store.get();
871
+ result = {
872
+ taskId,
873
+ task: normalized,
874
+ source,
875
+ status: final,
876
+ success: final === "completed",
877
+ summary: execution.data || "",
878
+ steps,
879
+ tokens: totalTokens,
880
+ durationMs: Date.now() - startedAt
881
+ };
882
+ } catch (err) {
883
+ const message = err instanceof Error ? err.message : String(err);
884
+ const { steps, totalTokens } = store.get();
885
+ store.set({
886
+ errorMessage: message,
887
+ status: "error"
888
+ });
889
+ result = {
890
+ taskId,
891
+ task: normalized,
892
+ source,
893
+ status: "error",
894
+ success: false,
895
+ summary: message,
896
+ errorCode: "AGENT_ERROR",
897
+ steps,
898
+ tokens: totalTokens,
899
+ durationMs: Date.now() - startedAt
900
+ };
901
+ } finally {
902
+ options.signal?.removeEventListener("abort", onAbort);
903
+ store.set({ finishedAt: Date.now() });
904
+ stopTick();
905
+ }
906
+ store.set({ lastResult: result });
907
+ pageUseHistory.add({
908
+ task: normalized,
909
+ source,
910
+ success: result.status === "stopped" ? null : result.success,
911
+ at: Date.now(),
912
+ steps: result.steps.length,
913
+ summary: result.summary
914
+ });
915
+ emit({
916
+ type: "finished",
917
+ taskId,
918
+ result
919
+ });
920
+ activeOptions = null;
921
+ return result;
922
+ }
923
+ /** 终止当前任务(无任务时忽略);等待 agent 完全停下后 resolve */
924
+ async function stopTask() {
925
+ await agent?.stop();
926
+ }
927
+ /** 订阅全部任务的进度事件;返回取消订阅函数 */
928
+ function subscribePageUse(listener) {
929
+ listeners.add(listener);
930
+ return () => {
931
+ listeners.delete(listener);
932
+ };
933
+ }
934
+ function getPageUseSnapshot() {
935
+ const state = store.get();
936
+ return {
937
+ ready: state.ready,
938
+ running: isRunning(state),
939
+ taskId: state.currentTaskId,
940
+ task: state.currentTask,
941
+ source: state.currentSource,
942
+ activity: state.activityText,
943
+ steps: state.steps.length
944
+ };
945
+ }
946
+ /**
947
+ * 清屏:清空面板展示的本次执行记录(指令、步骤、结果、计数),不影响历史指令与 agent 实例。
948
+ * 执行中不允许清空,否则后续步骤回写会与已清空的状态错位。
949
+ */
950
+ function clearPageUseLog() {
951
+ if (isRunning()) return;
952
+ store.set({
953
+ currentTask: "",
954
+ currentTaskId: "",
955
+ currentSource: "",
956
+ steps: [],
957
+ lastResult: null,
958
+ errorMessage: "",
959
+ activityText: "",
960
+ totalTokens: 0,
961
+ startedAt: 0,
962
+ finishedAt: 0,
963
+ status: "idle"
964
+ });
965
+ }
966
+ /** 释放 agent(清掉高亮与蒙层);宿主销毁(如登出)时调用 */
967
+ function disposeAgent() {
968
+ stopTick();
969
+ agent?.dispose();
970
+ agent = null;
971
+ agentMaskFlag = null;
972
+ activeOptions = null;
973
+ store.set({
974
+ maskEnabled: false,
975
+ status: "idle",
976
+ activityText: "",
977
+ pendingQuestion: null
978
+ });
979
+ }
980
+
981
+ //#endregion
982
+ //#region src/capability/api.ts
983
+ /**
984
+ * Page Use 对外能力门面:其它智能体通过它下达页面操作指令。
985
+ *
986
+ * 用法:
987
+ * import { pageUseCapability } from '@tanzerfe/page-use'
988
+ * if (pageUseCapability.isReady()) {
989
+ * const result = await pageUseCapability.run('打开订单列表并筛选待发货', { source: 'my-agent' })
990
+ * }
991
+ */
992
+ const pageUseCapability = {
993
+ version: 1,
994
+ isReady: () => getPageUseSnapshot().ready,
995
+ run: runTask,
996
+ stop: stopTask,
997
+ getSnapshot: getPageUseSnapshot,
998
+ subscribe: subscribePageUse
999
+ };
1000
+
1001
+ //#endregion
1002
+ //#region src/capability/tool.ts
1003
+ /** 工具名;对接方在 system 提示中引用时保持一致 */
1004
+ const PAGE_USE_TOOL_NAME = "operate_current_page";
1005
+ const PAGE_USE_TOOL = {
1006
+ type: "function",
1007
+ function: {
1008
+ name: PAGE_USE_TOOL_NAME,
1009
+ description: [
1010
+ "在用户当前打开的系统页面中,通过模拟真实点击与输入完成操作,并返回操作结论。",
1011
+ "可做:跨菜单跳转到任意页面、切换标签页、筛选与查询列表、打开详情抽屉或弹窗并读取内容、填写表单。",
1012
+ "不适合:纯知识问答、需要后台数据统计但页面上没有对应入口的问题。",
1013
+ "删除、提交、审批等不可撤销操作,须先向用户确认后再下达指令。",
1014
+ "同一时刻只能执行一个任务;执行期间页面被锁定,通常耗时 10~60 秒。"
1015
+ ].join(""),
1016
+ parameters: {
1017
+ type: "object",
1018
+ properties: { instruction: {
1019
+ type: "string",
1020
+ description: "可独立执行的完整中文指令,写明目标页面与具体操作,例如「打开订单管理下的订单列表,筛选状态为待发货并查询,告诉我第一条订单的收货地址」。"
1021
+ } },
1022
+ required: ["instruction"]
1023
+ }
1024
+ }
1025
+ };
1026
+ /** 解析工具入参:兼容对象与 JSON 字符串两种形式 */
1027
+ function readInstruction(args) {
1028
+ let value = args;
1029
+ if (typeof value === "string") try {
1030
+ value = JSON.parse(value);
1031
+ } catch {
1032
+ return value;
1033
+ }
1034
+ if (value && typeof value === "object" && "instruction" in value) {
1035
+ const instruction = value.instruction;
1036
+ return typeof instruction === "string" ? instruction : "";
1037
+ }
1038
+ return "";
1039
+ }
1040
+ /** 把任务结果整理为给模型阅读的纯文本 */
1041
+ function formatPageUseResultForModel(result) {
1042
+ const lines = [`页面操作${{
1043
+ completed: "已完成",
1044
+ failed: "未达成",
1045
+ stopped: "已被终止",
1046
+ error: "执行出错"
1047
+ }[result.status]}。`];
1048
+ if (result.errorCode) lines.push(`原因代码:${result.errorCode}`);
1049
+ if (result.summary) lines.push(`结论:${result.summary}`);
1050
+ if (result.steps.length) {
1051
+ const recent = result.steps.slice(-8);
1052
+ const omitted = result.steps.length - recent.length;
1053
+ lines.push(`执行步骤(共 ${result.steps.length} 步${omitted ? `,仅列最后 ${recent.length} 步` : ""}):`);
1054
+ for (const step of recent) lines.push(`${step.index}. ${step.action}${step.failed ? "(失败)" : ""}`);
1055
+ }
1056
+ return lines.join("\n");
1057
+ }
1058
+ /**
1059
+ * 执行一次 operate_current_page 工具调用。
1060
+ * 返回 { result, content }:content 直接作为 tool 消息回给模型,result 供对接方自行展示。
1061
+ */
1062
+ async function runPageUseTool(args, options = {}) {
1063
+ const result = await pageUseCapability.run(readInstruction(args), options);
1064
+ return {
1065
+ result,
1066
+ content: formatPageUseResultForModel(result)
1067
+ };
1068
+ }
1069
+ /** 指令块类型名:模型在 shell / JSON 块中输出 {"type":"pageAction","instruction":"..."} */
1070
+ const PAGE_USE_COMMAND_TYPE = "pageAction";
1071
+ function isPageUseCommand(value) {
1072
+ if (!value || typeof value !== "object") return false;
1073
+ const record = value;
1074
+ return record.type === "pageAction" && typeof record.instruction === "string" && record.instruction.trim().length > 0;
1075
+ }
1076
+
1077
+ //#endregion
1078
+ //#region src/capability/bridge.ts
1079
+ /**
1080
+ * 全局桥接:把能力挂到 window.__TZ_PAGE_USE__,供不走模块导入的调用方使用
1081
+ * (控制台调试、E2E 脚本、同源的外部脚本)。createPageUse 时安装,destroy 时移除。
1082
+ *
1083
+ * 仅支持同一 window 内调用,未实现 postMessage 跨窗口协议(iframe / 微前端);
1084
+ * 如需跨窗口接入,可在此基础上增加消息通道,能力接口保持不变。
1085
+ */
1086
+ /** window 上的挂载名 */
1087
+ const PAGE_USE_GLOBAL_KEY = "__TZ_PAGE_USE__";
1088
+ /** 安装全局桥接;返回卸载函数 */
1089
+ function installPageUseGlobal() {
1090
+ const api = {
1091
+ ...pageUseCapability,
1092
+ tool: PAGE_USE_TOOL,
1093
+ runTool: runPageUseTool
1094
+ };
1095
+ window[PAGE_USE_GLOBAL_KEY] = api;
1096
+ return () => {
1097
+ if (window["__TZ_PAGE_USE__"] === api) delete window[PAGE_USE_GLOBAL_KEY];
1098
+ };
1099
+ }
1100
+
1101
+ //#endregion
1102
+ //#region src/create.ts
1103
+ /**
1104
+ * 初始化入口:写入配置、安装全局桥接。UI 由 mountPageUseUI 单独挂载。
1105
+ */
1106
+ /**
1107
+ * 初始化 Page Use。全局只有一份配置:重复调用会以新配置替换旧配置,
1108
+ * 旧 handle 的 destroy 随之失效(不会误清新配置)。
1109
+ */
1110
+ function createPageUse(options) {
1111
+ const normalized = normalizeOptions(options);
1112
+ if (!normalized.llm.baseURL) console.warn("[PageUse] 未配置 llm.baseURL,run() 将返回 NOT_CONFIGURED");
1113
+ const unbind = configurePageUse(normalized);
1114
+ const uninstallGlobal = normalized.exposeGlobal ? installPageUseGlobal() : () => {};
1115
+ let destroyed = false;
1116
+ return {
1117
+ capability: pageUseCapability,
1118
+ destroy() {
1119
+ if (destroyed) return;
1120
+ destroyed = true;
1121
+ uninstallGlobal();
1122
+ unbind();
1123
+ }
1124
+ };
1125
+ }
1126
+
1127
+ //#endregion
1128
+ //#region src/ui/glow.ts
1129
+ /**
1130
+ * <tz-page-use-glow>:运行期光晕层(旋转渐变边框 + 外发光呼吸 + 四周暗角 + 底部状态提示)。
1131
+ * 只做视觉,pointer-events: none,点击拦截仍由 page-agent 的 SimulatorMask 负责。
1132
+ * 用户点到蒙层(被拦截的误触)时切换为警示色并提示如何中断。
1133
+ *
1134
+ * 显示条件:执行中 + 当前 agent 实例带蒙层 + 开启定制效果。
1135
+ * enableMask 是构造期选项,设置改动要到下次任务重建实例才生效,故读 state.maskEnabled 而非设置值。
1136
+ */
1137
+ const PAGE_USE_GLOW_TAG = "tz-page-use-glow";
1138
+ /** 误触警示持续时间(ms) */
1139
+ const BLOCKED_DURATION = 1600;
1140
+ /** 旋转渐变角度变量名;须在 document 级注册(shadow 样式表中的 @property 不生效) */
1141
+ const ANGLE_VAR = "--tz-page-use-glow-angle";
1142
+ let angleRegistered = false;
1143
+ function registerAngleProperty() {
1144
+ if (angleRegistered) return;
1145
+ angleRegistered = true;
1146
+ try {
1147
+ CSS.registerProperty({
1148
+ name: ANGLE_VAR,
1149
+ syntax: "<angle>",
1150
+ inherits: true,
1151
+ initialValue: "0deg"
1152
+ });
1153
+ } catch {}
1154
+ }
1155
+ var PageUseGlowElement = class extends LitElement {
1156
+ static styles = css`
1157
+ :host {
1158
+ /* 主题色与 maskTheme.ts 的 PAGE_USE_MASK_COLORS 保持一致,可由宿主覆盖 */
1159
+ --c-primary: var(--tz-page-use-primary, #0879ff);
1160
+ --c-cyan: var(--tz-page-use-cyan, #36cfc9);
1161
+ --c-indigo: var(--tz-page-use-indigo, #597ef7);
1162
+ --c-warning: var(--tz-page-use-warning, #fa8c16);
1163
+ }
1164
+
1165
+ /*
1166
+ * 层级:高于 SimulatorMask(2147483641)以覆盖其上显示,
1167
+ * 低于调试面板与入口(2147483643),不遮挡「终止」按钮。
1168
+ */
1169
+ .glow {
1170
+ --c-a: var(--c-primary);
1171
+ --c-b: var(--c-cyan);
1172
+ --c-c: var(--c-indigo);
1173
+ position: fixed;
1174
+ inset: 0;
1175
+ z-index: 2147483642;
1176
+ pointer-events: none;
1177
+ opacity: 0;
1178
+ visibility: hidden;
1179
+ transition: opacity 0.35s ease, visibility 0s linear 0.35s;
1180
+ animation: glow-spin 6s linear infinite paused;
1181
+ }
1182
+ .glow.show {
1183
+ opacity: 1;
1184
+ visibility: visible;
1185
+ transition: opacity 0.35s ease;
1186
+ animation-play-state: running;
1187
+ }
1188
+
1189
+ /* 误触:整体切换为警示色 */
1190
+ .blocked {
1191
+ --c-a: var(--c-warning);
1192
+ --c-b: #ffc53d;
1193
+ --c-c: #ff7a45;
1194
+ }
1195
+
1196
+ /* 四周暗角 + 极淡主色罩,提示页面处于锁定态但不影响阅读 */
1197
+ .vignette {
1198
+ position: absolute;
1199
+ inset: 0;
1200
+ background:
1201
+ radial-gradient(ellipse at center, transparent 55%, color-mix(in srgb, var(--c-a) 14%, transparent) 100%),
1202
+ color-mix(in srgb, var(--c-a) 3%, transparent);
1203
+ transition: background 0.3s;
1204
+ }
1205
+
1206
+ /* 通用:用双层遮罩只保留边框区域(padding 即边框宽度) */
1207
+ .ring,
1208
+ .aura {
1209
+ position: absolute;
1210
+ inset: 0;
1211
+ background: conic-gradient(
1212
+ from var(--tz-page-use-glow-angle, 0deg),
1213
+ var(--c-a),
1214
+ var(--c-b),
1215
+ var(--c-c),
1216
+ var(--c-a)
1217
+ );
1218
+ -webkit-mask:
1219
+ linear-gradient(#000 0 0) content-box,
1220
+ linear-gradient(#000 0 0);
1221
+ -webkit-mask-composite: xor;
1222
+ mask-composite: exclude;
1223
+ }
1224
+ .ring {
1225
+ padding: 3px;
1226
+ }
1227
+ /* 外发光:更宽的边框 + 模糊 + 呼吸 */
1228
+ .aura {
1229
+ padding: 14px;
1230
+ filter: blur(14px);
1231
+ opacity: 0.55;
1232
+ animation: glow-breathe 2.4s ease-in-out infinite;
1233
+ }
1234
+ .blocked .aura {
1235
+ opacity: 0.9;
1236
+ animation: none;
1237
+ }
1238
+
1239
+ /* 底部状态提示条 */
1240
+ .hint {
1241
+ position: absolute;
1242
+ left: 50%;
1243
+ bottom: 28px;
1244
+ display: flex;
1245
+ align-items: center;
1246
+ gap: 8px;
1247
+ max-width: min(640px, calc(100vw - 48px));
1248
+ padding: 8px 16px;
1249
+ border: 1px solid color-mix(in srgb, var(--c-a) 35%, transparent);
1250
+ border-radius: 999px;
1251
+ background: rgba(255, 255, 255, 0.92);
1252
+ box-shadow: 0 6px 24px color-mix(in srgb, var(--c-a) 30%, transparent);
1253
+ color: #1f2d3d;
1254
+ font-size: 13px;
1255
+ font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
1256
+ transform: translateX(-50%);
1257
+ backdrop-filter: blur(6px);
1258
+ }
1259
+ .blocked .hint {
1260
+ animation: glow-shake 0.4s ease-in-out;
1261
+ }
1262
+ .hint-dot {
1263
+ flex: none;
1264
+ width: 8px;
1265
+ height: 8px;
1266
+ border-radius: 50%;
1267
+ background: var(--c-a);
1268
+ animation: glow-ping 1.4s ease-out infinite;
1269
+ }
1270
+ .hint-text {
1271
+ overflow: hidden;
1272
+ text-overflow: ellipsis;
1273
+ white-space: nowrap;
1274
+ }
1275
+
1276
+ @keyframes glow-spin {
1277
+ to {
1278
+ --tz-page-use-glow-angle: 360deg;
1279
+ }
1280
+ }
1281
+ @keyframes glow-breathe {
1282
+ 0%,
1283
+ 100% {
1284
+ opacity: 0.4;
1285
+ }
1286
+ 50% {
1287
+ opacity: 0.75;
1288
+ }
1289
+ }
1290
+ @keyframes glow-ping {
1291
+ 0% {
1292
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--c-a) 60%, transparent);
1293
+ }
1294
+ 100% {
1295
+ box-shadow: 0 0 0 8px transparent;
1296
+ }
1297
+ }
1298
+ @keyframes glow-shake {
1299
+ 0%,
1300
+ 100% {
1301
+ transform: translateX(-50%);
1302
+ }
1303
+ 25% {
1304
+ transform: translateX(calc(-50% - 6px));
1305
+ }
1306
+ 75% {
1307
+ transform: translateX(calc(-50% + 6px));
1308
+ }
1309
+ }
1310
+ @media (prefers-reduced-motion: reduce) {
1311
+ .glow,
1312
+ .aura,
1313
+ .hint-dot,
1314
+ .blocked .hint {
1315
+ animation: none;
1316
+ }
1317
+ }
1318
+ `;
1319
+ blocked = false;
1320
+ /** 每次误触自增,作为提示条 key 重建节点以重播抖动动画 */
1321
+ blockCount = 0;
1322
+ blockedTimer = null;
1323
+ active = false;
1324
+ unsubscribers = [];
1325
+ connectedCallback() {
1326
+ super.connectedCallback();
1327
+ registerAngleProperty();
1328
+ const sync = () => this.syncActive();
1329
+ this.unsubscribers = [pageUseStore.subscribe(sync), pageUseSettings.store.subscribe(sync)];
1330
+ this.syncActive();
1331
+ }
1332
+ disconnectedCallback() {
1333
+ super.disconnectedCallback();
1334
+ this.unsubscribers.forEach((off) => off());
1335
+ this.unsubscribers = [];
1336
+ this.setListening(false);
1337
+ this.resetBlocked();
1338
+ }
1339
+ syncActive() {
1340
+ const state = pageUseStore.get();
1341
+ const active = isRunning(state) && state.maskEnabled && pageUseSettings.get().customMaskEffect;
1342
+ if (active !== this.active) {
1343
+ this.active = active;
1344
+ this.setListening(active);
1345
+ if (!active) this.resetBlocked();
1346
+ }
1347
+ this.requestUpdate();
1348
+ }
1349
+ /**
1350
+ * 捕获阶段监听:SimulatorMask 在自身节点上 stopPropagation,
1351
+ * 冒泡阶段收不到,必须在 window 捕获阶段判断点击目标是否落在蒙层内。
1352
+ */
1353
+ onPointerDownCapture = (event) => {
1354
+ const target = event.target;
1355
+ if (!(target instanceof Element) || !target.closest(`#${"page-agent-runtime_simulator-mask"}`)) return;
1356
+ this.blocked = true;
1357
+ this.blockCount += 1;
1358
+ if (this.blockedTimer !== null) clearTimeout(this.blockedTimer);
1359
+ this.blockedTimer = setTimeout(() => {
1360
+ this.blocked = false;
1361
+ this.blockedTimer = null;
1362
+ this.requestUpdate();
1363
+ }, BLOCKED_DURATION);
1364
+ this.requestUpdate();
1365
+ };
1366
+ setListening(on) {
1367
+ window.removeEventListener("pointerdown", this.onPointerDownCapture, true);
1368
+ if (on) window.addEventListener("pointerdown", this.onPointerDownCapture, true);
1369
+ }
1370
+ resetBlocked() {
1371
+ if (this.blockedTimer !== null) clearTimeout(this.blockedTimer);
1372
+ this.blockedTimer = null;
1373
+ this.blocked = false;
1374
+ }
1375
+ render() {
1376
+ const activity = pageUseStore.get().activityText;
1377
+ const hint = this.blocked ? "AI 正在操作,已拦截本次点击;如需中断请在面板点「终止」" : activity ? `AI 正在操作页面 · ${activity}` : "AI 正在操作页面,请勿点击";
1378
+ return html`
1379
+ <div
1380
+ class="glow ${this.active ? "show" : ""} ${this.blocked ? "blocked" : ""}"
1381
+ aria-hidden="true"
1382
+ >
1383
+ <div class="vignette"></div>
1384
+ <div class="aura"></div>
1385
+ <div class="ring"></div>
1386
+ ${keyed(this.blockCount, html`<div class="hint">
1387
+ <span class="hint-dot"></span>
1388
+ <span class="hint-text">${hint}</span>
1389
+ </div>`)}
1390
+ </div>
1391
+ `;
1392
+ }
1393
+ };
1394
+
1395
+ //#endregion
1396
+ //#region src/ui/drag.ts
1397
+ /** 判定为拖拽而非点击的最小位移(px) */
1398
+ const DRAG_THRESHOLD = 4;
1399
+ /** 元素与视口边缘保留的最小间距(px) */
1400
+ const VIEWPORT_MARGIN = 8;
1401
+ function clampToViewport(pos, el) {
1402
+ const width = el?.offsetWidth ?? 0;
1403
+ const height = el?.offsetHeight ?? 0;
1404
+ const maxX = Math.max(8, window.innerWidth - width - 8);
1405
+ const maxY = Math.max(8, window.innerHeight - height - 8);
1406
+ return {
1407
+ x: Math.min(Math.max(8, pos.x), maxX),
1408
+ y: Math.min(Math.max(8, pos.y), maxY)
1409
+ };
1410
+ }
1411
+ function createDrag(options) {
1412
+ function onPointerDown(event) {
1413
+ if (event.button !== 0) return;
1414
+ const el = options.getEl();
1415
+ if (!el) return;
1416
+ if (options.skipInteractive !== false) {
1417
+ if (event.composedPath()[0]?.closest?.("button, input, textarea, select, a, label, [data-no-drag]")) return;
1418
+ }
1419
+ const handle = event.currentTarget;
1420
+ const rect = el.getBoundingClientRect();
1421
+ const startX = event.clientX;
1422
+ const startY = event.clientY;
1423
+ let moved = false;
1424
+ let dragging = true;
1425
+ const onMove = (e) => {
1426
+ if (!dragging) return;
1427
+ const dx = e.clientX - startX;
1428
+ const dy = e.clientY - startY;
1429
+ if (!moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
1430
+ moved = true;
1431
+ options.setPos(clampToViewport({
1432
+ x: rect.left + dx,
1433
+ y: rect.top + dy
1434
+ }, el));
1435
+ };
1436
+ const onUp = () => {
1437
+ dragging = false;
1438
+ handle.removeEventListener("pointermove", onMove);
1439
+ handle.removeEventListener("pointerup", onUp);
1440
+ handle.removeEventListener("pointercancel", onUp);
1441
+ try {
1442
+ handle.releasePointerCapture(event.pointerId);
1443
+ } catch {}
1444
+ if (!moved) options.onTap?.();
1445
+ };
1446
+ handle.setPointerCapture(event.pointerId);
1447
+ handle.addEventListener("pointermove", onMove);
1448
+ handle.addEventListener("pointerup", onUp);
1449
+ handle.addEventListener("pointercancel", onUp);
1450
+ event.preventDefault();
1451
+ }
1452
+ /** 视口尺寸变化后把已存位置夹回可见区域 */
1453
+ function reclamp() {
1454
+ const pos = options.getPos();
1455
+ if (!pos) return;
1456
+ options.setPos(clampToViewport(pos, options.getEl()));
1457
+ }
1458
+ return {
1459
+ onPointerDown,
1460
+ reclamp
1461
+ };
1462
+ }
1463
+
1464
+ //#endregion
1465
+ //#region src/ui/presets.ts
1466
+ /** 通用示例:不依赖具体站点,覆盖详情、标签、返回等典型操作 */
1467
+ const PAGE_USE_STATIC_PRESETS = [
1468
+ {
1469
+ label: "看第一条详情",
1470
+ task: "在当前列表页打开第一条记录的详情"
1471
+ },
1472
+ {
1473
+ label: "切到下一个标签",
1474
+ task: "切换到当前页面的下一个标签页"
1475
+ },
1476
+ {
1477
+ label: "总结当前页",
1478
+ task: "概括当前页面展示的主要内容与可执行的操作"
1479
+ }
1480
+ ];
1481
+ /** 取前 N 个可见叶子菜单,生成「打开 XX」快捷指令 */
1482
+ function buildMenuPresets(menus, limit = 8) {
1483
+ const out = [];
1484
+ const walk = (list) => {
1485
+ for (const node of list) {
1486
+ if (out.length >= limit) return;
1487
+ if (node.hidden) continue;
1488
+ const children = (node.children || []).filter((child) => !child.hidden);
1489
+ if (children.length) walk(children);
1490
+ else out.push({
1491
+ label: node.label,
1492
+ task: `打开「${node.label}」页面`
1493
+ });
1494
+ }
1495
+ };
1496
+ walk(menus || []);
1497
+ return out;
1498
+ }
1499
+
1500
+ //#endregion
1501
+ //#region src/ui/styles.ts
1502
+ /**
1503
+ * 调试面板样式(Shadow DOM 内生效,与宿主样式双向隔离)。
1504
+ * 不依赖组件库,以原生元素 + 类名实现:btn / checkbox / alert / spin / timeline。
1505
+ * 主题色可由宿主通过 CSS 变量 --tz-page-use-primary 等覆盖。
1506
+ */
1507
+ const launcherStyles = css`
1508
+ :host {
1509
+ --primary: var(--tz-page-use-primary, #0879ff);
1510
+ --success: var(--tz-page-use-success, #1a9d4b);
1511
+ --warning: var(--tz-page-use-warning-text, #f5a623);
1512
+ --error: var(--tz-page-use-error, #d9363e);
1513
+ --text: #1f2d3d;
1514
+ --text-3: #8a94a6;
1515
+ --border: #e6eaf0;
1516
+ --bg-soft: #f5f7fa;
1517
+ font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
1518
+ font-size: 13px;
1519
+ line-height: 1.5;
1520
+ color: var(--text);
1521
+ }
1522
+ *,
1523
+ *::before,
1524
+ *::after {
1525
+ box-sizing: border-box;
1526
+ }
1527
+ [hidden] {
1528
+ display: none !important;
1529
+ }
1530
+
1531
+ /*
1532
+ * 滚动条:细轨道、透明底、圆角滑块,悬停时加深。
1533
+ * 标准属性(Chrome 121+ / Firefox)优先生效;旧版 WebKit 走 ::-webkit-scrollbar。
1534
+ */
1535
+ .fill,
1536
+ .tray,
1537
+ .textarea {
1538
+ scrollbar-width: thin;
1539
+ scrollbar-color: rgba(31, 45, 61, 0.18) transparent;
1540
+ }
1541
+ .fill:hover,
1542
+ .tray:hover,
1543
+ .textarea:hover {
1544
+ scrollbar-color: rgba(31, 45, 61, 0.32) transparent;
1545
+ }
1546
+ .fill::-webkit-scrollbar,
1547
+ .tray::-webkit-scrollbar,
1548
+ .textarea::-webkit-scrollbar {
1549
+ width: 6px;
1550
+ height: 6px;
1551
+ }
1552
+ .fill::-webkit-scrollbar-track,
1553
+ .tray::-webkit-scrollbar-track,
1554
+ .textarea::-webkit-scrollbar-track {
1555
+ background: transparent;
1556
+ }
1557
+ .fill::-webkit-scrollbar-thumb,
1558
+ .tray::-webkit-scrollbar-thumb,
1559
+ .textarea::-webkit-scrollbar-thumb {
1560
+ border-radius: 3px;
1561
+ background: rgba(31, 45, 61, 0.18);
1562
+ }
1563
+ .fill::-webkit-scrollbar-thumb:hover,
1564
+ .tray::-webkit-scrollbar-thumb:hover,
1565
+ .textarea::-webkit-scrollbar-thumb:hover {
1566
+ background: rgba(31, 45, 61, 0.32);
1567
+ }
1568
+
1569
+ /*
1570
+ * 运行期蒙层 z-index 为 2147483641、内置面板为 2147483642;
1571
+ * 入口与面板须高于两者,否则任务执行中无法点「终止」或回答模型提问。
1572
+ */
1573
+ .fab {
1574
+ position: fixed;
1575
+ z-index: 2147483643;
1576
+ display: flex;
1577
+ align-items: center;
1578
+ gap: 6px;
1579
+ padding: 8px 14px;
1580
+ border: none;
1581
+ border-radius: 20px;
1582
+ background: var(--primary);
1583
+ color: #fff;
1584
+ font: inherit;
1585
+ font-weight: 600;
1586
+ cursor: grab;
1587
+ touch-action: none;
1588
+ box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
1589
+ }
1590
+ .fab:active {
1591
+ cursor: grabbing;
1592
+ }
1593
+ .fab.running {
1594
+ background: var(--warning);
1595
+ box-shadow: 0 4px 14px color-mix(in srgb, var(--warning) 40%, transparent);
1596
+ }
1597
+ .fab-dot {
1598
+ width: 6px;
1599
+ height: 6px;
1600
+ border-radius: 50%;
1601
+ background: #fff;
1602
+ }
1603
+ .fab.running .fab-dot {
1604
+ animation: blink 1s infinite;
1605
+ }
1606
+
1607
+ .panel {
1608
+ position: fixed;
1609
+ z-index: 2147483643;
1610
+ /* width / height 由 panelStyle 按入口位置计算 */
1611
+ display: flex;
1612
+ flex-direction: column;
1613
+ border-radius: 10px;
1614
+ background: #fff;
1615
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
1616
+ overflow: hidden;
1617
+ }
1618
+
1619
+ .head {
1620
+ display: flex;
1621
+ align-items: center;
1622
+ gap: 8px;
1623
+ padding: 10px 12px;
1624
+ background: var(--bg-soft);
1625
+ border-bottom: 1px solid var(--border);
1626
+ cursor: grab;
1627
+ touch-action: none;
1628
+ user-select: none;
1629
+ }
1630
+ .head:active {
1631
+ cursor: grabbing;
1632
+ }
1633
+ .dot {
1634
+ flex: none;
1635
+ width: 8px;
1636
+ height: 8px;
1637
+ border-radius: 50%;
1638
+ background: #b5bfcc;
1639
+ }
1640
+ .dot.running {
1641
+ background: var(--primary);
1642
+ animation: blink 1s infinite;
1643
+ }
1644
+ .dot.completed {
1645
+ background: var(--success);
1646
+ }
1647
+ .dot.error {
1648
+ background: var(--error);
1649
+ }
1650
+ .dot.stopped {
1651
+ background: var(--warning);
1652
+ }
1653
+ /* 标题不被压缩,空间不足时由右侧状态文本省略 */
1654
+ .title {
1655
+ flex: 1 0 auto;
1656
+ font-weight: 600;
1657
+ }
1658
+ .status {
1659
+ min-width: 0;
1660
+ overflow: hidden;
1661
+ color: var(--text-3);
1662
+ font-size: 12px;
1663
+ text-overflow: ellipsis;
1664
+ white-space: nowrap;
1665
+ }
1666
+
1667
+ /* 占满头部与输入区之间的剩余高度(min-height:0 允许在 flex 列中收缩) */
1668
+ .fill {
1669
+ flex: 1;
1670
+ min-height: 0;
1671
+ overflow-y: auto;
1672
+ overscroll-behavior: contain;
1673
+ }
1674
+ .settings-body,
1675
+ .log-body {
1676
+ display: flex;
1677
+ flex-direction: column;
1678
+ gap: 10px;
1679
+ padding: 12px;
1680
+ }
1681
+
1682
+ .muted {
1683
+ color: var(--text-3);
1684
+ }
1685
+ .hint {
1686
+ display: block;
1687
+ color: var(--text-3);
1688
+ font-size: 12px;
1689
+ }
1690
+ .meta {
1691
+ color: var(--text-3);
1692
+ font-size: 12px;
1693
+ word-break: break-all;
1694
+ }
1695
+ .row {
1696
+ display: flex;
1697
+ flex-wrap: wrap;
1698
+ gap: 8px;
1699
+ }
1700
+
1701
+ /* ---------- 按钮 ---------- */
1702
+ .btn {
1703
+ display: inline-flex;
1704
+ align-items: center;
1705
+ justify-content: center;
1706
+ gap: 4px;
1707
+ height: 28px;
1708
+ padding: 0 12px;
1709
+ border: 1px solid #d0d7e2;
1710
+ border-radius: 4px;
1711
+ background: #fff;
1712
+ color: var(--text);
1713
+ font: inherit;
1714
+ white-space: nowrap;
1715
+ cursor: pointer;
1716
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
1717
+ }
1718
+ .btn:hover:not(:disabled) {
1719
+ border-color: var(--primary);
1720
+ color: var(--primary);
1721
+ }
1722
+ .btn:disabled {
1723
+ cursor: not-allowed;
1724
+ opacity: 0.5;
1725
+ }
1726
+ .btn.tiny {
1727
+ height: 22px;
1728
+ padding: 0 8px;
1729
+ font-size: 12px;
1730
+ }
1731
+ .btn.round {
1732
+ border-radius: 999px;
1733
+ }
1734
+ .btn.circle {
1735
+ width: 28px;
1736
+ padding: 0;
1737
+ border-radius: 50%;
1738
+ }
1739
+ .btn.tiny.circle {
1740
+ width: 22px;
1741
+ }
1742
+ .btn.primary {
1743
+ border-color: var(--primary);
1744
+ background: var(--primary);
1745
+ color: #fff;
1746
+ }
1747
+ .btn.primary:hover:not(:disabled) {
1748
+ color: #fff;
1749
+ filter: brightness(1.08);
1750
+ }
1751
+ .btn.danger {
1752
+ border-color: var(--error);
1753
+ background: var(--error);
1754
+ color: #fff;
1755
+ }
1756
+ .btn.danger:hover:not(:disabled) {
1757
+ color: #fff;
1758
+ filter: brightness(1.08);
1759
+ }
1760
+ /* 次要:浅色填充无边框 */
1761
+ .btn.secondary {
1762
+ border-color: transparent;
1763
+ background: #eef1f5;
1764
+ }
1765
+ .btn.secondary.accent {
1766
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
1767
+ color: var(--primary);
1768
+ }
1769
+ /* 幽灵:仅悬停时显示底色 */
1770
+ .btn.ghost {
1771
+ border-color: transparent;
1772
+ background: transparent;
1773
+ }
1774
+ .btn.ghost:hover:not(:disabled) {
1775
+ background: #eef1f5;
1776
+ color: var(--text);
1777
+ }
1778
+ .btn.ghost.active {
1779
+ color: var(--primary);
1780
+ }
1781
+
1782
+ /* ---------- 复选框 ---------- */
1783
+ .checkbox {
1784
+ display: flex;
1785
+ align-items: flex-start;
1786
+ gap: 8px;
1787
+ cursor: pointer;
1788
+ }
1789
+ .checkbox input {
1790
+ flex: none;
1791
+ margin: 3px 0 0;
1792
+ accent-color: var(--primary);
1793
+ }
1794
+ .checkbox.disabled {
1795
+ cursor: not-allowed;
1796
+ opacity: 0.5;
1797
+ }
1798
+
1799
+ /* ---------- 提示框 ---------- */
1800
+ .alert {
1801
+ padding: 8px 12px;
1802
+ border: 1px solid;
1803
+ border-radius: 6px;
1804
+ word-break: break-word;
1805
+ }
1806
+ /* 正文保留模型返回的换行;pre-wrap 只放在正文上,模板缩进产生的空白不会被渲染 */
1807
+ .alert-body {
1808
+ white-space: pre-wrap;
1809
+ }
1810
+ .alert-title {
1811
+ margin-bottom: 4px;
1812
+ font-weight: 600;
1813
+ }
1814
+ .alert.success {
1815
+ border-color: color-mix(in srgb, var(--success) 35%, transparent);
1816
+ background: color-mix(in srgb, var(--success) 8%, #fff);
1817
+ }
1818
+ .alert.error {
1819
+ border-color: color-mix(in srgb, var(--error) 35%, transparent);
1820
+ background: color-mix(in srgb, var(--error) 8%, #fff);
1821
+ }
1822
+ .alert.warning {
1823
+ border-color: color-mix(in srgb, var(--warning) 45%, transparent);
1824
+ background: color-mix(in srgb, var(--warning) 10%, #fff);
1825
+ }
1826
+
1827
+ /* ---------- 加载指示 ---------- */
1828
+ .spin {
1829
+ flex: none;
1830
+ width: 14px;
1831
+ height: 14px;
1832
+ border: 2px solid color-mix(in srgb, var(--primary) 25%, transparent);
1833
+ border-top-color: var(--primary);
1834
+ border-radius: 50%;
1835
+ animation: spin 0.8s linear infinite;
1836
+ }
1837
+ .activity {
1838
+ display: flex;
1839
+ align-items: center;
1840
+ gap: 6px;
1841
+ color: var(--primary);
1842
+ }
1843
+
1844
+ /* ---------- 日志 ---------- */
1845
+ .empty {
1846
+ display: flex;
1847
+ flex-direction: column;
1848
+ gap: 8px;
1849
+ }
1850
+ .chips {
1851
+ display: flex;
1852
+ flex-wrap: wrap;
1853
+ gap: 6px;
1854
+ }
1855
+ .chips-caption {
1856
+ width: 100%;
1857
+ color: var(--text-3);
1858
+ font-size: 12px;
1859
+ }
1860
+ .task-source {
1861
+ align-self: flex-end;
1862
+ color: var(--text-3);
1863
+ font-size: 12px;
1864
+ }
1865
+ /* 本次指令原文,右对齐以区分用户输入与执行日志 */
1866
+ .task-bubble {
1867
+ align-self: flex-end;
1868
+ max-width: 88%;
1869
+ padding: 6px 10px;
1870
+ border-radius: 10px 10px 2px 10px;
1871
+ background: var(--primary);
1872
+ color: #fff;
1873
+ white-space: pre-wrap;
1874
+ word-break: break-word;
1875
+ }
1876
+
1877
+ /* 时间线:左侧竖线 + 彩色节点 */
1878
+ .timeline {
1879
+ margin: 0;
1880
+ padding: 0;
1881
+ list-style: none;
1882
+ }
1883
+ .timeline li {
1884
+ position: relative;
1885
+ padding: 0 0 12px 20px;
1886
+ }
1887
+ .timeline li::before {
1888
+ content: '';
1889
+ position: absolute;
1890
+ left: 4px;
1891
+ top: 14px;
1892
+ bottom: 0;
1893
+ width: 1px;
1894
+ background: var(--border);
1895
+ }
1896
+ .timeline li:last-child {
1897
+ padding-bottom: 0;
1898
+ }
1899
+ .timeline li:last-child::before {
1900
+ display: none;
1901
+ }
1902
+ .timeline-dot {
1903
+ position: absolute;
1904
+ left: 0;
1905
+ top: 5px;
1906
+ width: 9px;
1907
+ height: 9px;
1908
+ border: 2px solid var(--primary);
1909
+ border-radius: 50%;
1910
+ background: #fff;
1911
+ }
1912
+ .timeline-dot.success {
1913
+ border-color: var(--success);
1914
+ }
1915
+ .timeline-dot.warning {
1916
+ border-color: var(--warning);
1917
+ }
1918
+ .timeline-dot.error {
1919
+ border-color: var(--error);
1920
+ }
1921
+ .step-action {
1922
+ word-break: break-word;
1923
+ }
1924
+ .step-detail {
1925
+ display: block;
1926
+ color: var(--text-3);
1927
+ font-size: 12px;
1928
+ word-break: break-word;
1929
+ }
1930
+
1931
+ /* ---------- 输入区:固定在面板底部,不随日志滚动 ---------- */
1932
+ .composer {
1933
+ position: relative;
1934
+ flex: none;
1935
+ display: flex;
1936
+ flex-direction: column;
1937
+ gap: 8px;
1938
+ padding: 10px 12px 12px;
1939
+ border-top: 1px solid var(--border);
1940
+ background: #fafbfc;
1941
+ }
1942
+ /* 悬浮在输入区上沿(以 .composer 为定位参照) */
1943
+ .jump-latest {
1944
+ position: absolute;
1945
+ right: 16px;
1946
+ bottom: calc(100% + 8px);
1947
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1948
+ }
1949
+ .input,
1950
+ .textarea {
1951
+ width: 100%;
1952
+ padding: 4px 10px;
1953
+ border: 1px solid #d0d7e2;
1954
+ border-radius: 4px;
1955
+ background: #fff;
1956
+ color: var(--text);
1957
+ font: inherit;
1958
+ outline: none;
1959
+ transition: border-color 0.15s, box-shadow 0.15s;
1960
+ }
1961
+ .input:focus,
1962
+ .textarea:focus {
1963
+ border-color: var(--primary);
1964
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 18%, transparent);
1965
+ }
1966
+ .textarea {
1967
+ display: block;
1968
+ /* 高度由脚本按内容在 2~6 行之间自适应 */
1969
+ line-height: 20px;
1970
+ resize: none;
1971
+ overflow-y: auto;
1972
+ }
1973
+ .textarea:disabled {
1974
+ background: var(--bg-soft);
1975
+ cursor: not-allowed;
1976
+ }
1977
+ .input-group {
1978
+ display: flex;
1979
+ gap: 6px;
1980
+ margin-top: 6px;
1981
+ }
1982
+ .input-group .input {
1983
+ flex: 1;
1984
+ min-width: 0;
1985
+ height: 28px;
1986
+ }
1987
+
1988
+ /* 托盘限高,内容多时在托盘内部滚动,不挤压输入框 */
1989
+ .tray {
1990
+ display: flex;
1991
+ flex-direction: column;
1992
+ gap: 8px;
1993
+ max-height: 180px;
1994
+ padding: 8px;
1995
+ overflow-y: auto;
1996
+ border: 1px solid var(--border);
1997
+ border-radius: 8px;
1998
+ background: #fff;
1999
+ }
2000
+ .history-item {
2001
+ display: flex;
2002
+ align-items: center;
2003
+ gap: 4px;
2004
+ }
2005
+ .history-task {
2006
+ flex: 1;
2007
+ min-width: 0;
2008
+ justify-content: flex-start;
2009
+ gap: 6px;
2010
+ }
2011
+ .history-text {
2012
+ overflow: hidden;
2013
+ text-overflow: ellipsis;
2014
+ white-space: nowrap;
2015
+ }
2016
+ .ok {
2017
+ color: var(--success);
2018
+ }
2019
+ .fail {
2020
+ color: var(--error);
2021
+ }
2022
+
2023
+ .toolbar {
2024
+ display: flex;
2025
+ align-items: center;
2026
+ gap: 6px;
2027
+ }
2028
+ /* 重跑 / 执行 / 终止 靠右 */
2029
+ .push-right {
2030
+ margin-left: auto;
2031
+ }
2032
+
2033
+ @keyframes blink {
2034
+ 0%,
2035
+ 100% {
2036
+ opacity: 1;
2037
+ }
2038
+ 50% {
2039
+ opacity: 0.25;
2040
+ }
2041
+ }
2042
+ @keyframes spin {
2043
+ to {
2044
+ transform: rotate(360deg);
2045
+ }
2046
+ }
2047
+ `;
2048
+
2049
+ //#endregion
2050
+ //#region src/ui/launcher.ts
2051
+ /**
2052
+ * <tz-page-use-launcher>:调试入口(可拖拽浮动按钮 + 指令面板)。
2053
+ * 与其它智能体共用 core/service 的单例状态:其它智能体下达的任务同样在此展示,可随时终止。
2054
+ * 默认隐藏 page-agent 自带面板与元素编号高亮,进度与提问改由本面板承接(设置里可开回)。
2055
+ *
2056
+ * 宿主元素须带 data-page-agent-ignore="true"(由 mountPageUseUI 设置):
2057
+ * page-controller 在该属性上直接跳过整棵子树(含 shadow root),面板不会被模型当作页面元素。
2058
+ */
2059
+ const PAGE_USE_LAUNCHER_TAG = "tz-page-use-launcher";
2060
+ /** 本面板下达任务时使用的来源标识 */
2061
+ const LAUNCHER_SOURCE = "launcher";
2062
+ const DEFAULT_SOURCE_LABELS = { unknown: "外部调用" };
2063
+ /** AgentStatus → 中文 */
2064
+ const STATUS_LABELS = {
2065
+ idle: "就绪",
2066
+ running: "执行中",
2067
+ completed: "已完成",
2068
+ error: "出错",
2069
+ stopped: "已终止"
2070
+ };
2071
+ /** 入口默认位置:距视口右边/下边的距离(px) */
2072
+ const FAB_DEFAULT_RIGHT = 28;
2073
+ const FAB_DEFAULT_BOTTOM = 140;
2074
+ /** 面板宽度(px),与入口对齐及视口夹取计算共用 */
2075
+ const PANEL_WIDTH = 380;
2076
+ /** 面板期望高度(px),受视口 72vh 与锚点剩余空间约束 */
2077
+ const PANEL_HEIGHT = 560;
2078
+ /** 日志区距底部小于该值(px)视为贴底 */
2079
+ const STICK_THRESHOLD = 24;
2080
+ const TEXTAREA_MIN_ROWS = 2;
2081
+ /**
2082
+ * 步骤 → 时间线节点颜色:失败/报错为 error,完成为 success,
2083
+ * 向用户提问与重试为 warning,其余普通操作为 info(主色)。
2084
+ */
2085
+ function stepType(step) {
2086
+ if (step.failed || step.tool === "error") return "error";
2087
+ if (step.tool === "done") return "success";
2088
+ if (step.tool === "ask_user" || step.tool === "retry") return "warning";
2089
+ return "info";
2090
+ }
2091
+ function toStyle(style) {
2092
+ return Object.entries(style).map(([key, value]) => `${key}:${value}`).join(";");
2093
+ }
2094
+ var PageUseLauncherElement = class extends LitElement {
2095
+ static styles = launcherStyles;
2096
+ _config = {};
2097
+ /** 面板配置;赋值后立即重新渲染 */
2098
+ get config() {
2099
+ return this._config;
2100
+ }
2101
+ set config(value) {
2102
+ this._config = value || {};
2103
+ this.requestUpdate();
2104
+ }
2105
+ open = false;
2106
+ showSettings = false;
2107
+ task = "";
2108
+ answer = "";
2109
+ tray = null;
2110
+ /**
2111
+ * 日志区跟随滚动:默认贴底跟随新内容;用户上翻后停止跟随,
2112
+ * 回到底部附近(阈值内)或点击「最新」后恢复。
2113
+ */
2114
+ stickToBottom = true;
2115
+ /** 上次渲染时日志内容的摘要;仅内容变化时才贴底,避免每秒的耗时刷新把用户拉回底部 */
2116
+ logSignature = "";
2117
+ /** 下次渲染强制滚到底部(点击「最新」、任务开始) */
2118
+ forceScroll = false;
2119
+ /**
2120
+ * 入口尺寸缓存:入口在面板展开时被隐藏,offsetWidth 为 0,
2121
+ * 因此在可见时测量并保留最近一次结果;初值为「Page Use」文案下的近似尺寸。
2122
+ */
2123
+ fabSize = {
2124
+ width: 110,
2125
+ height: 34
2126
+ };
2127
+ viewport = {
2128
+ width: window.innerWidth,
2129
+ height: window.innerHeight
2130
+ };
2131
+ /**
2132
+ * 面板相对入口的对齐方式:入口在视口右半边则面板右边缘对齐入口右边缘,否则左对齐;
2133
+ * 下半边则底边对齐(面板向上展开),否则顶边对齐。
2134
+ * 仅在展开时计算一次并在展开期间保持,避免拖动面板越过中线时锚点切换导致跳动。
2135
+ */
2136
+ panelAnchor = {
2137
+ alignRight: true,
2138
+ alignBottom: true
2139
+ };
2140
+ wasRunning = false;
2141
+ cleanups = [];
2142
+ get fabEl() {
2143
+ return this.renderRoot.querySelector(".fab");
2144
+ }
2145
+ get panelEl() {
2146
+ return this.renderRoot.querySelector(".panel");
2147
+ }
2148
+ get logEl() {
2149
+ return this.renderRoot.querySelector(".log");
2150
+ }
2151
+ get textareaEl() {
2152
+ return this.renderRoot.querySelector(".textarea");
2153
+ }
2154
+ fabDrag = createDrag({
2155
+ getEl: () => this.fabEl,
2156
+ getPos: () => pageUseSettings.get().fabPos,
2157
+ setPos: (pos) => pageUseSettings.update({ fabPos: pos }),
2158
+ onTap: () => {
2159
+ this.measureFab();
2160
+ this.resolvePanelAnchor();
2161
+ this.open = true;
2162
+ this.requestUpdate();
2163
+ },
2164
+ skipInteractive: false
2165
+ });
2166
+ /** 拖动面板时反推入口位置:面板锚定角即入口所在角,收起后入口停在面板原位置 */
2167
+ panelDrag = createDrag({
2168
+ getEl: () => this.panelEl,
2169
+ getPos: () => null,
2170
+ setPos: ({ x, y }) => {
2171
+ const el = this.panelEl;
2172
+ if (!el) return;
2173
+ const { width: fabWidth, height: fabHeight } = this.fabSize;
2174
+ const { alignRight, alignBottom } = this.panelAnchor;
2175
+ pageUseSettings.update({ fabPos: {
2176
+ x: alignRight ? x + el.offsetWidth - fabWidth : x,
2177
+ y: alignBottom ? y + el.offsetHeight - fabHeight : y
2178
+ } });
2179
+ }
2180
+ });
2181
+ connectedCallback() {
2182
+ super.connectedCallback();
2183
+ this.wasRunning = isRunning();
2184
+ this.cleanups = [
2185
+ pageUseStore.subscribe((state) => this.onStateChange(state)),
2186
+ pageUseSettings.store.subscribe(() => this.requestUpdate()),
2187
+ pageUseHistory.store.subscribe(() => this.requestUpdate()),
2188
+ registerQuestionUi()
2189
+ ];
2190
+ window.addEventListener("resize", this.onResize);
2191
+ }
2192
+ disconnectedCallback() {
2193
+ super.disconnectedCallback();
2194
+ this.cleanups.forEach((off) => off());
2195
+ this.cleanups = [];
2196
+ window.removeEventListener("resize", this.onResize);
2197
+ }
2198
+ firstUpdated() {
2199
+ this.measureFab();
2200
+ }
2201
+ updated(_changed) {
2202
+ this.autosizeTextarea();
2203
+ const state = pageUseStore.get();
2204
+ const signature = [
2205
+ state.currentTask,
2206
+ state.steps.length,
2207
+ state.activityText,
2208
+ state.errorMessage,
2209
+ state.lastResult?.taskId ?? "",
2210
+ state.pendingQuestion?.question ?? ""
2211
+ ].join("");
2212
+ const contentChanged = signature !== this.logSignature;
2213
+ this.logSignature = signature;
2214
+ if (this.forceScroll || this.stickToBottom && contentChanged) {
2215
+ this.forceScroll = false;
2216
+ const log = this.logEl;
2217
+ if (log) log.scrollTop = log.scrollHeight;
2218
+ }
2219
+ }
2220
+ onStateChange(state) {
2221
+ const running = isRunning(state);
2222
+ if (running && !this.wasRunning) {
2223
+ this.tray = null;
2224
+ this.stickToBottom = true;
2225
+ this.forceScroll = true;
2226
+ }
2227
+ this.wasRunning = running;
2228
+ this.requestUpdate();
2229
+ }
2230
+ onResize = () => {
2231
+ this.viewport = {
2232
+ width: window.innerWidth,
2233
+ height: window.innerHeight
2234
+ };
2235
+ if (!this.open) this.fabDrag.reclamp();
2236
+ this.requestUpdate();
2237
+ };
2238
+ measureFab() {
2239
+ const el = this.fabEl;
2240
+ if (el?.offsetWidth) this.fabSize = {
2241
+ width: el.offsetWidth,
2242
+ height: el.offsetHeight
2243
+ };
2244
+ }
2245
+ /** 入口在视口中的矩形;未拖动过时按默认 right/bottom 推算 */
2246
+ fabRect(settings) {
2247
+ const { width, height } = this.fabSize;
2248
+ const left = settings.fabPos?.x ?? this.viewport.width - FAB_DEFAULT_RIGHT - width;
2249
+ const top = settings.fabPos?.y ?? this.viewport.height - FAB_DEFAULT_BOTTOM - height;
2250
+ return {
2251
+ left,
2252
+ top,
2253
+ right: left + width,
2254
+ bottom: top + height
2255
+ };
2256
+ }
2257
+ resolvePanelAnchor() {
2258
+ const rect = this.fabRect(pageUseSettings.get());
2259
+ this.panelAnchor = {
2260
+ alignRight: (rect.left + rect.right) / 2 > this.viewport.width / 2,
2261
+ alignBottom: (rect.top + rect.bottom) / 2 > this.viewport.height / 2
2262
+ };
2263
+ }
2264
+ /** 未拖动过时用默认角落定位,拖动后切换为 left/top */
2265
+ fabStyle(settings) {
2266
+ return toStyle(settings.fabPos ? {
2267
+ left: `${settings.fabPos.x}px`,
2268
+ top: `${settings.fabPos.y}px`
2269
+ } : {
2270
+ right: `${FAB_DEFAULT_RIGHT}px`,
2271
+ bottom: `${FAB_DEFAULT_BOTTOM}px`
2272
+ });
2273
+ }
2274
+ /** 面板位置完全由入口位置推导,展开时与入口重合于同一角 */
2275
+ panelStyle(settings) {
2276
+ const rect = this.fabRect(settings);
2277
+ const { width: vw, height: vh } = this.viewport;
2278
+ const { alignRight, alignBottom } = this.panelAnchor;
2279
+ const maxOffsetX = Math.max(8, vw - PANEL_WIDTH - 8);
2280
+ const clampX = (value) => Math.min(Math.max(8, value), maxOffsetX);
2281
+ const style = { width: `${PANEL_WIDTH}px` };
2282
+ if (alignRight) style.right = `${clampX(vw - rect.right)}px`;
2283
+ else style.left = `${clampX(rect.left)}px`;
2284
+ const offsetY = Math.max(8, alignBottom ? vh - rect.bottom : rect.top);
2285
+ if (alignBottom) style.bottom = `${offsetY}px`;
2286
+ else style.top = `${offsetY}px`;
2287
+ style.height = `min(${PANEL_HEIGHT}px, 72vh, ${Math.max(240, vh - offsetY - 8)}px)`;
2288
+ return toStyle(style);
2289
+ }
2290
+ async collapse() {
2291
+ this.open = false;
2292
+ this.requestUpdate();
2293
+ await this.updateComplete;
2294
+ this.measureFab();
2295
+ this.fabDrag.reclamp();
2296
+ }
2297
+ async submit() {
2298
+ const text = this.task.trim();
2299
+ if (isRunning() || !text) return;
2300
+ this.task = "";
2301
+ this.requestUpdate();
2302
+ await runTask(text, { source: LAUNCHER_SOURCE });
2303
+ }
2304
+ applyPreset(text) {
2305
+ this.task = text;
2306
+ this.submit();
2307
+ }
2308
+ lastTaskText(state) {
2309
+ return state.currentTask || pageUseHistory.entries()[0]?.task || "";
2310
+ }
2311
+ /** 回车执行、Shift+回车换行;输入法组词中的回车不触发执行 */
2312
+ onTaskKeydown(event) {
2313
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
2314
+ event.preventDefault();
2315
+ this.submit();
2316
+ }
2317
+ onTaskInput(event) {
2318
+ this.task = event.target.value;
2319
+ this.requestUpdate();
2320
+ }
2321
+ /** 按内容在 2~6 行之间调整输入框高度,超出后内部滚动 */
2322
+ autosizeTextarea() {
2323
+ const el = this.textareaEl;
2324
+ if (!el) return;
2325
+ const style = getComputedStyle(el);
2326
+ const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
2327
+ const border = (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0);
2328
+ const min = 40 + padding + border;
2329
+ const max = 120 + padding + border;
2330
+ el.style.height = "auto";
2331
+ el.style.height = `${Math.min(max, Math.max(min, el.scrollHeight + border))}px`;
2332
+ }
2333
+ submitAnswer() {
2334
+ const text = this.answer.trim();
2335
+ if (!text) return;
2336
+ pageUseStore.get().pendingQuestion?.resolve(text);
2337
+ this.answer = "";
2338
+ this.requestUpdate();
2339
+ }
2340
+ onLogScroll(event) {
2341
+ const el = event.target;
2342
+ const stick = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
2343
+ if (stick === this.stickToBottom) return;
2344
+ this.stickToBottom = stick;
2345
+ this.requestUpdate();
2346
+ }
2347
+ /**
2348
+ * 滚轮上翻立即停止跟随:平滑滚动的首个 scroll 事件位移可能小于阈值,
2349
+ * 若此时仍判定为贴底,内容刷新会把视图拉回底部,表现为上翻时抖动。
2350
+ */
2351
+ onLogWheel(event) {
2352
+ if (event.deltaY >= 0 || !this.stickToBottom) return;
2353
+ this.stickToBottom = false;
2354
+ this.requestUpdate();
2355
+ }
2356
+ scrollLogToBottom() {
2357
+ this.stickToBottom = true;
2358
+ this.forceScroll = true;
2359
+ this.requestUpdate();
2360
+ }
2361
+ toggleTray(kind) {
2362
+ this.tray = this.tray === kind ? null : kind;
2363
+ this.requestUpdate();
2364
+ }
2365
+ onClearLog() {
2366
+ clearPageUseLog();
2367
+ this.tray = null;
2368
+ this.stickToBottom = true;
2369
+ this.requestUpdate();
2370
+ }
2371
+ sourceLabel(state) {
2372
+ const source = state.currentSource;
2373
+ if (!source || source === LAUNCHER_SOURCE) return "";
2374
+ return {
2375
+ ...DEFAULT_SOURCE_LABELS,
2376
+ ...this._config.sourceLabels
2377
+ }[source] || source;
2378
+ }
2379
+ presets() {
2380
+ const limit = this._config.menuPresetLimit ?? 6;
2381
+ const menus = getPageUseConfig()?.getMenus();
2382
+ return [
2383
+ ...limit > 0 ? buildMenuPresets(menus, limit) : [],
2384
+ ...PAGE_USE_STATIC_PRESETS,
2385
+ ...this._config.presets || []
2386
+ ];
2387
+ }
2388
+ endpointLabel() {
2389
+ const baseURL = getPageUseConfig()?.llm.baseURL;
2390
+ if (!baseURL) return "未配置";
2391
+ try {
2392
+ return new URL(baseURL).host;
2393
+ } catch {
2394
+ return baseURL;
2395
+ }
2396
+ }
2397
+ renderChips(presets, accent = false) {
2398
+ return presets.map((preset) => html`<button
2399
+ type="button"
2400
+ class="btn tiny round secondary ${accent ? "accent" : ""}"
2401
+ title=${preset.task}
2402
+ @click=${() => this.applyPreset(preset.task)}
2403
+ >
2404
+ ${preset.label}
2405
+ </button>`);
2406
+ }
2407
+ renderPresetGroups() {
2408
+ const scenarios = this._config.scenarios || [];
2409
+ return html`
2410
+ <div class="chips">
2411
+ <span class="chips-caption">常用</span>
2412
+ ${this.renderChips(this.presets())}
2413
+ </div>
2414
+ ${scenarios.length ? html`<div class="chips">
2415
+ <span class="chips-caption">复杂场景</span>
2416
+ ${this.renderChips(scenarios, true)}
2417
+ </div>` : nothing}
2418
+ `;
2419
+ }
2420
+ renderCheckbox(key, label, hint, disabled = false) {
2421
+ const settings = pageUseSettings.get();
2422
+ return html`<label class="checkbox ${disabled ? "disabled" : ""}">
2423
+ <input
2424
+ type="checkbox"
2425
+ .checked=${Boolean(settings[key])}
2426
+ ?disabled=${disabled}
2427
+ @change=${(e) => pageUseSettings.update({ [key]: e.target.checked })}
2428
+ />
2429
+ <span>${label}${hint ? html`<span class="hint">${hint}</span>` : nothing}</span>
2430
+ </label>`;
2431
+ }
2432
+ renderSettings() {
2433
+ const settings = pageUseSettings.get();
2434
+ const config = getPageUseConfig();
2435
+ return html`<div class="fill">
2436
+ <div class="settings-body">
2437
+ ${this.renderCheckbox("showBuiltinPanel", "显示 page-agent 自带面板", "关闭后由本面板展示进度")}
2438
+ ${this.renderCheckbox("showHighlights", "显示元素编号高亮", "运行时页面上带数字的彩色框")}
2439
+ ${this.renderCheckbox("enableMask", "运行时蒙层防误触", "下次任务开始生效")}
2440
+ ${this.renderCheckbox("customMaskEffect", "定制光晕与光标配色", "关闭后恢复 page-agent 默认效果", !settings.enableMask)}
2441
+ ${config?.beforeTask ? this.renderCheckbox("enableBeforeTask", config.beforeTaskLabel) : nothing}
2442
+ ${this.renderCheckbox("showStepDetails", "步骤显示模型思考与返回")}
2443
+ <div class="row">
2444
+ <button type="button" class="btn" @click=${() => pageUseSettings.resetPositions()}>
2445
+ 复位窗口
2446
+ </button>
2447
+ <button type="button" class="btn" @click=${() => pageUseHistory.clear()}>清空历史</button>
2448
+ </div>
2449
+ <span class="meta">端点 ${this.endpointLabel()} · 模型 ${config?.llm.model ?? "-"}</span>
2450
+ </div>
2451
+ </div>`;
2452
+ }
2453
+ renderLog(state, isLogEmpty) {
2454
+ const running = isRunning(state);
2455
+ const showDetails = pageUseSettings.get().showStepDetails;
2456
+ const sourceLabel = this.sourceLabel(state);
2457
+ return html`<div class="fill log" @scroll=${this.onLogScroll} @wheel=${this.onLogWheel}>
2458
+ <div class="log-body">
2459
+ ${isLogEmpty ? html`<div class="empty">
2460
+ <strong>用自然语言操作当前系统页面</strong>
2461
+ <span class="hint">可直接输入指令,或从下方快捷指令开始</span>
2462
+ ${this.renderPresetGroups()}
2463
+ </div>` : html`
2464
+ ${state.currentTask && sourceLabel ? html`<span class="task-source">来自${sourceLabel}</span>` : nothing}
2465
+ ${state.currentTask ? html`<div class="task-bubble">${state.currentTask}</div>` : nothing}
2466
+ ${state.steps.length ? html`<ol class="timeline">
2467
+ ${state.steps.map((step) => html`<li>
2468
+ <span class="timeline-dot ${stepType(step)}"></span>
2469
+ <span class="step-action">${step.action}</span>
2470
+ ${showDetails && step.goal ? html`<span class="step-detail">${step.goal}</span>` : nothing}
2471
+ ${showDetails && step.output ? html`<span class="step-detail">${step.output}</span>` : nothing}
2472
+ </li>`)}
2473
+ </ol>` : nothing}
2474
+ ${running && state.activityText ? html`<div class="activity"><span class="spin"></span>${state.activityText}</div>` : nothing}
2475
+ ${state.errorMessage ? html`<div class="alert error">
2476
+ <div class="alert-body">${state.errorMessage}</div>
2477
+ </div>` : state.lastResult && !running ? html`<div class="alert ${state.lastResult.success ? "success" : "error"}">
2478
+ <div class="alert-title">${state.lastResult.success ? "已完成" : "未完成"}</div>
2479
+ ${state.lastResult.summary ? html`<div class="alert-body">${state.lastResult.summary}</div>` : nothing}
2480
+ </div>` : nothing}
2481
+ `}
2482
+ </div>
2483
+ </div>`;
2484
+ }
2485
+ renderTray() {
2486
+ if (this.tray === "presets") return html`<div class="tray">${this.renderPresetGroups()}</div>`;
2487
+ const entries = pageUseHistory.entries();
2488
+ return html`<div class="tray">
2489
+ ${entries.length ? nothing : html`<span class="hint">暂无历史指令</span>`}
2490
+ ${entries.map((entry) => html`<div class="history-item">
2491
+ <button
2492
+ type="button"
2493
+ class="btn ghost history-task"
2494
+ title=${entry.summary || entry.task}
2495
+ @click=${() => this.applyPreset(entry.task)}
2496
+ >
2497
+ <span class=${entry.success === false ? "fail" : "ok"}>
2498
+ ${entry.success === false ? "✕" : "✓"}
2499
+ </span>
2500
+ <span class="history-text">${entry.task}</span>
2501
+ </button>
2502
+ <button
2503
+ type="button"
2504
+ class="btn ghost tiny circle"
2505
+ title="删除"
2506
+ @click=${() => pageUseHistory.remove(entry.id)}
2507
+ >
2508
+ ×
2509
+ </button>
2510
+ </div>`)}
2511
+ </div>`;
2512
+ }
2513
+ renderComposer(state, isLogEmpty) {
2514
+ const running = isRunning(state);
2515
+ const lastTask = this.lastTaskText(state);
2516
+ const historyCount = pageUseHistory.entries().length;
2517
+ const question = state.pendingQuestion;
2518
+ return html`<footer class="composer">
2519
+ ${!this.stickToBottom && !isLogEmpty ? html`<button
2520
+ type="button"
2521
+ class="btn tiny round jump-latest"
2522
+ @click=${() => this.scrollLogToBottom()}
2523
+ >
2524
+ ↓ 最新
2525
+ </button>` : nothing}
2526
+ ${question ? html`<div class="alert warning">
2527
+ <div class="alert-title">${question.question}</div>
2528
+ <div class="input-group">
2529
+ <input
2530
+ class="input"
2531
+ placeholder="输入回答后回车"
2532
+ .value=${this.answer}
2533
+ @input=${(e) => {
2534
+ this.answer = e.target.value;
2535
+ }}
2536
+ @keydown=${(e) => {
2537
+ if (e.key === "Enter" && !e.isComposing) {
2538
+ e.preventDefault();
2539
+ this.submitAnswer();
2540
+ }
2541
+ }}
2542
+ />
2543
+ <button type="button" class="btn primary" @click=${() => this.submitAnswer()}>
2544
+ 回答
2545
+ </button>
2546
+ </div>
2547
+ </div>` : nothing}
2548
+ ${this.tray && !running ? this.renderTray() : nothing}
2549
+ <textarea
2550
+ class="textarea"
2551
+ rows=${TEXTAREA_MIN_ROWS}
2552
+ placeholder=${running ? "执行中,可点击「终止」中断" : "输入指令,回车执行,Shift+回车换行"}
2553
+ ?disabled=${running}
2554
+ .value=${this.task}
2555
+ @input=${this.onTaskInput}
2556
+ @keydown=${this.onTaskKeydown}
2557
+ ></textarea>
2558
+ <div class="toolbar">
2559
+ <button
2560
+ type="button"
2561
+ class="btn tiny ${this.tray === "presets" ? "secondary accent" : "ghost"}"
2562
+ ?disabled=${running}
2563
+ @click=${() => this.toggleTray("presets")}
2564
+ >
2565
+ 快捷指令
2566
+ </button>
2567
+ <button
2568
+ type="button"
2569
+ class="btn tiny ${this.tray === "history" ? "secondary accent" : "ghost"}"
2570
+ ?disabled=${running}
2571
+ @click=${() => this.toggleTray("history")}
2572
+ >
2573
+ 历史${historyCount ? `(${historyCount})` : ""}
2574
+ </button>
2575
+ <button
2576
+ type="button"
2577
+ class="btn tiny ghost"
2578
+ ?disabled=${running || isLogEmpty}
2579
+ title="清空上方执行记录(历史指令保留)"
2580
+ @click=${this.onClearLog}
2581
+ >
2582
+ 清屏
2583
+ </button>
2584
+ ${running ? html`<button type="button" class="btn danger push-right" @click=${() => void stopTask()}>
2585
+ 终止
2586
+ </button>` : html`
2587
+ <button
2588
+ type="button"
2589
+ class="btn push-right"
2590
+ ?disabled=${!lastTask}
2591
+ title=${lastTask ? `重跑:${lastTask}` : "暂无可重跑的指令"}
2592
+ @click=${() => lastTask && this.applyPreset(lastTask)}
2593
+ >
2594
+ 重跑
2595
+ </button>
2596
+ <button
2597
+ type="button"
2598
+ class="btn primary"
2599
+ ?disabled=${!this.task.trim()}
2600
+ @click=${() => void this.submit()}
2601
+ >
2602
+ 执行
2603
+ </button>
2604
+ `}
2605
+ </div>
2606
+ </footer>`;
2607
+ }
2608
+ render() {
2609
+ const state = pageUseStore.get();
2610
+ const settings = pageUseSettings.get();
2611
+ const running = isRunning(state);
2612
+ const title = this._config.title || "Page Use";
2613
+ const elapsed = getElapsedSeconds(state);
2614
+ const isLogEmpty = !running && !state.currentTask && !state.steps.length && !state.errorMessage;
2615
+ return html`
2616
+ <button
2617
+ type="button"
2618
+ class="fab ${running ? "running" : ""}"
2619
+ ?hidden=${this.open}
2620
+ style=${this.fabStyle(settings)}
2621
+ title=${running ? `执行中:${state.activityText || state.currentTask}` : `打开 ${title} 页面操作`}
2622
+ @pointerdown=${this.fabDrag.onPointerDown}
2623
+ >
2624
+ <span class="fab-dot"></span>
2625
+ <span>${running ? "执行中" : title}</span>
2626
+ </button>
2627
+
2628
+ <section class="panel" ?hidden=${!this.open} style=${this.open ? this.panelStyle(settings) : ""}>
2629
+ <header class="head" @pointerdown=${this.panelDrag.onPointerDown}>
2630
+ <span class="dot ${state.status}"></span>
2631
+ <span class="title">${title}</span>
2632
+ <span class="status">
2633
+ ${STATUS_LABELS[state.status] || state.status}${state.steps.length ? ` · ${state.steps.length} 步` : ""}${state.totalTokens ? ` · ${state.totalTokens} tok` : ""}${elapsed ? ` · ${elapsed}s` : ""}
2634
+ </span>
2635
+ <button
2636
+ type="button"
2637
+ class="btn ghost circle ${this.showSettings ? "active" : ""}"
2638
+ title=${this.showSettings ? "返回" : "设置"}
2639
+ @click=${() => {
2640
+ this.showSettings = !this.showSettings;
2641
+ this.requestUpdate();
2642
+ }}
2643
+ >
2644
+
2645
+ </button>
2646
+ <button type="button" class="btn ghost circle" title="收起" @click=${() => void this.collapse()}>
2647
+
2648
+ </button>
2649
+ </header>
2650
+ ${this.showSettings ? this.renderSettings() : html`${this.renderLog(state, isLogEmpty)}${this.renderComposer(state, isLogEmpty)}`}
2651
+ </section>
2652
+ `;
2653
+ }
2654
+ };
2655
+
2656
+ //#endregion
2657
+ //#region src/ui/mount.ts
2658
+ /**
2659
+ * UI 挂载:注册自定义元素,并把光晕层与调试面板挂到 document.body。
2660
+ * 框架无关,Vue / React / 原生页面均可直接调用。
2661
+ */
2662
+ /** 注册 <tz-page-use-glow> 与 <tz-page-use-launcher>;重复调用安全(已注册则跳过) */
2663
+ function definePageUseElements() {
2664
+ if (typeof customElements === "undefined") return;
2665
+ if (!customElements.get("tz-page-use-glow")) customElements.define(PAGE_USE_GLOW_TAG, PageUseGlowElement);
2666
+ if (!customElements.get("tz-page-use-launcher")) customElements.define(PAGE_USE_LAUNCHER_TAG, PageUseLauncherElement);
2667
+ }
2668
+ /**
2669
+ * 挂载 UI,返回卸载函数。
2670
+ * 外层容器带 data-page-agent-ignore="true"(值必须为字符串 "true"):
2671
+ * page-controller 按 dataset 值精确判断后跳过整棵子树,面板与光晕不会被模型当作页面元素。
2672
+ */
2673
+ function mountPageUseUI(options = {}) {
2674
+ if (typeof document === "undefined") return () => {};
2675
+ definePageUseElements();
2676
+ const root = document.createElement("div");
2677
+ root.setAttribute("data-page-agent-ignore", "true");
2678
+ root.setAttribute("data-browser-use-ignore", "true");
2679
+ root.setAttribute("data-page-use-root", "");
2680
+ if (options.glow !== false) root.appendChild(document.createElement(PAGE_USE_GLOW_TAG));
2681
+ if (options.launcher) {
2682
+ const launcher = document.createElement(PAGE_USE_LAUNCHER_TAG);
2683
+ launcher.config = typeof options.launcher === "object" ? options.launcher : {};
2684
+ root.appendChild(launcher);
2685
+ }
2686
+ (options.container || document.body).appendChild(root);
2687
+ return () => {
2688
+ root.remove();
2689
+ };
2690
+ }
2691
+
2692
+ //#endregion
2693
+ export { DEFAULT_MODEL as A, pageUseStore as C, SIMULATOR_MASK_ID as D, PAGE_USE_MASK_COLORS as E, DEFAULT_STORAGE_KEY_PREFIX as M, normalizeOptions as N, pageUseHistory as O, readLauncherSwitch as P, isRunning as S, pageUseSettings as T, pageUseCapability as _, PAGE_USE_STATIC_PRESETS as a, getPageUseConfig as b, PageUseGlowElement as c, PAGE_USE_COMMAND_TYPE as d, PAGE_USE_TOOL as f, runPageUseTool as g, isPageUseCommand as h, PageUseLauncherElement as i, DEFAULT_SESSION_HEADER as j, DEFAULT_MAX_STEPS as k, createPageUse as l, formatPageUseResultForModel as m, mountPageUseUI as n, buildMenuPresets as o, PAGE_USE_TOOL_NAME as p, PAGE_USE_LAUNCHER_TAG as r, PAGE_USE_GLOW_TAG as s, definePageUseElements as t, PAGE_USE_GLOBAL_KEY as u, clearPageUseLog as v, DEFAULT_PAGE_USE_SETTINGS as w, isPageUseConfigured as x, getElapsedSeconds as y };