@tanzerfe/page-use 0.0.2 → 0.0.4
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.
- package/README.md +22 -0
- package/dist/{index-DtJ3mvL2.d.mts → index-CD2K3C_Q.d.mts} +11 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-C8EWDZVz.mjs → src-B9wNTD0L.mjs} +768 -364
- package/dist/vue.d.mts +1 -1
- package/dist/vue.mjs +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,634 @@ import { PageAgent } from "page-agent";
|
|
|
2
2
|
import { LitElement, css, html, nothing } from "lit";
|
|
3
3
|
import { keyed } from "lit/directives/keyed.js";
|
|
4
4
|
|
|
5
|
+
//#region src/core/maskTheme.ts
|
|
6
|
+
/**
|
|
7
|
+
* 运行期蒙层与模拟光标的定制主题(覆盖 page-agent SimulatorMask 的默认视觉)。
|
|
8
|
+
*
|
|
9
|
+
* page-agent 的 CSS Module 类名带哈希(如 `_cursor_1dgwb_2`),随版本变化,
|
|
10
|
+
* 因此这里只依赖稳定的结构:
|
|
11
|
+
* - 蒙层根节点 id:`page-agent-runtime_simulator-mask`
|
|
12
|
+
* - 子节点顺序:ai-motion 光晕层在前(初始化失败时不存在),光标节点恒为最后一个子节点
|
|
13
|
+
* - 光标子节点顺序:1 点击波纹 / 2 箭头填充 / 3 箭头描边
|
|
14
|
+
* - 点击态类名包含 `clicking` 子串
|
|
15
|
+
* 升级 page-agent 后若光标样式失效,先核对 SimulatorMask 的 #createCursor 结构。
|
|
16
|
+
*/
|
|
17
|
+
/** 蒙层根节点 id(@page-agent/page-controller 内部常量) */
|
|
18
|
+
const SIMULATOR_MASK_ID = "page-agent-runtime_simulator-mask";
|
|
19
|
+
/** 主题色:主色 #0879ff 同系的蓝 → 青 → 靛渐变 */
|
|
20
|
+
const PAGE_USE_MASK_COLORS = {
|
|
21
|
+
primary: "#0879ff",
|
|
22
|
+
cyan: "#36cfc9",
|
|
23
|
+
indigo: "#597ef7",
|
|
24
|
+
/** 误触拦截时的警示色 */
|
|
25
|
+
warning: "#fa8c16"
|
|
26
|
+
};
|
|
27
|
+
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>`)}")`;
|
|
28
|
+
const MASK = `#${SIMULATOR_MASK_ID}`;
|
|
29
|
+
/** 光标节点:蒙层的最后一个子节点 */
|
|
30
|
+
const CURSOR = `${MASK} > :last-child`;
|
|
31
|
+
const { primary, cyan, indigo } = PAGE_USE_MASK_COLORS;
|
|
32
|
+
const PAGE_USE_MASK_THEME_CSS = `
|
|
33
|
+
/* 隐藏 ai-motion 默认光晕(光标之外的子节点),光晕改由 <tz-page-use-glow> 渲染 */
|
|
34
|
+
${MASK} > :not(:last-child) { display: none !important; }
|
|
35
|
+
/* 蒙层本身保持透明,仅负责拦截点击;真实鼠标由 wait 转圈改为禁止符号,明确「此时不可操作」而非「页面卡死」 */
|
|
36
|
+
${MASK} { cursor: not-allowed !important; }
|
|
37
|
+
|
|
38
|
+
/* 光标整体外发光 */
|
|
39
|
+
${CURSOR} {
|
|
40
|
+
filter: drop-shadow(0 0 6px ${primary}8c) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
|
41
|
+
}
|
|
42
|
+
/* 光标尖端(节点原点)的呼吸光圈 */
|
|
43
|
+
${CURSOR}::before {
|
|
44
|
+
content: '';
|
|
45
|
+
position: absolute;
|
|
46
|
+
left: -16px;
|
|
47
|
+
top: -16px;
|
|
48
|
+
width: 32px;
|
|
49
|
+
height: 32px;
|
|
50
|
+
border-radius: 50%;
|
|
51
|
+
background: radial-gradient(circle, ${cyan}66 0%, ${primary}26 55%, transparent 72%);
|
|
52
|
+
animation: page-use-cursor-halo 1.6s ease-in-out infinite;
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
}
|
|
55
|
+
/* 箭头描边:主题渐变 */
|
|
56
|
+
${CURSOR} > :nth-child(3) {
|
|
57
|
+
background: linear-gradient(45deg, ${primary}, ${cyan}) !important;
|
|
58
|
+
}
|
|
59
|
+
/* 箭头填充:用同形遮罩替换原白色 SVG 背景,改为浅色渐变 */
|
|
60
|
+
${CURSOR} > :nth-child(2) {
|
|
61
|
+
background: linear-gradient(135deg, #ffffff 30%, #e6f4ff) !important;
|
|
62
|
+
-webkit-mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
|
|
63
|
+
mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
|
|
64
|
+
}
|
|
65
|
+
/* 点击波纹:外环改主题色 */
|
|
66
|
+
${CURSOR} > :nth-child(1)::after {
|
|
67
|
+
border-color: ${cyan} !important;
|
|
68
|
+
border-width: 3px !important;
|
|
69
|
+
}
|
|
70
|
+
/* 点击波纹:追加一层实心扩散,增强点击反馈 */
|
|
71
|
+
${CURSOR} > :nth-child(1)::before {
|
|
72
|
+
content: '';
|
|
73
|
+
position: absolute;
|
|
74
|
+
inset: 25%;
|
|
75
|
+
border-radius: 50%;
|
|
76
|
+
background: radial-gradient(circle, ${indigo}80, transparent 70%);
|
|
77
|
+
opacity: 0;
|
|
78
|
+
}
|
|
79
|
+
${CURSOR}[class*='clicking'] > :nth-child(1)::before {
|
|
80
|
+
animation: page-use-cursor-press 420ms ease-out forwards;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@keyframes page-use-cursor-halo {
|
|
84
|
+
0%, 100% { transform: scale(0.85); opacity: 0.7; }
|
|
85
|
+
50% { transform: scale(1.25); opacity: 1; }
|
|
86
|
+
}
|
|
87
|
+
@keyframes page-use-cursor-press {
|
|
88
|
+
0% { transform: scale(0.4); opacity: 1; }
|
|
89
|
+
100% { transform: scale(2.4); opacity: 0; }
|
|
90
|
+
}
|
|
91
|
+
@media (prefers-reduced-motion: reduce) {
|
|
92
|
+
${CURSOR}::before { animation: none; }
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/core/options.ts
|
|
98
|
+
/** 默认模型,仅为示例取值;接入其它端点时应通过 llm.model 显式指定支持 function calling 的模型 */
|
|
99
|
+
const DEFAULT_MODEL = "deepseek-chat";
|
|
100
|
+
/** 单次任务最大步数;每步一次 LLM 调用,防止死循环消耗 token */
|
|
101
|
+
const DEFAULT_MAX_STEPS = 20;
|
|
102
|
+
/** 默认会话头:OpenCode Go 要求每个会话带稳定的 x-opencode-session 用于路由与提示缓存 */
|
|
103
|
+
const DEFAULT_SESSION_HEADER = "x-opencode-session";
|
|
104
|
+
/** localStorage / sessionStorage 键前缀,可通过 storageKeyPrefix 覆盖 */
|
|
105
|
+
const DEFAULT_STORAGE_KEY_PREFIX = "tz-page-use";
|
|
106
|
+
/** 相对路径补齐为当前 origin 的绝对地址并去掉末尾 /;无法解析时返回空串 */
|
|
107
|
+
function toAbsoluteUrl(input) {
|
|
108
|
+
const base = typeof window !== "undefined" ? window.location.origin : void 0;
|
|
109
|
+
try {
|
|
110
|
+
return new URL(input, base).toString().replace(/\/+$/, "");
|
|
111
|
+
} catch {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** 按路由模式从 location 读取当前路径 */
|
|
116
|
+
function readLocationPath(mode) {
|
|
117
|
+
if (typeof window === "undefined") return "/";
|
|
118
|
+
if (mode === "hash") return window.location.hash.replace(/^#/, "") || "/";
|
|
119
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
120
|
+
}
|
|
121
|
+
function resolveSessionHeader(value) {
|
|
122
|
+
if (value === false) return "";
|
|
123
|
+
return (value ?? "").trim() || "x-opencode-session";
|
|
124
|
+
}
|
|
125
|
+
function normalizeOptions(options) {
|
|
126
|
+
const { llm } = options;
|
|
127
|
+
const rawBase = (llm.baseURL || "").trim();
|
|
128
|
+
const routerMode = options.routerMode ?? "hash";
|
|
129
|
+
return {
|
|
130
|
+
llm: {
|
|
131
|
+
baseURL: rawBase ? toAbsoluteUrl(rawBase) : "",
|
|
132
|
+
apiKey: (llm.apiKey || "").trim(),
|
|
133
|
+
model: (llm.model || "").trim() || "deepseek-chat",
|
|
134
|
+
maxSteps: llm.maxSteps && llm.maxSteps > 0 ? llm.maxSteps : 20,
|
|
135
|
+
disableNamedToolChoice: Boolean(llm.disableNamedToolChoice),
|
|
136
|
+
extraBody: llm.extraBody && typeof llm.extraBody === "object" ? llm.extraBody : {},
|
|
137
|
+
sessionHeader: resolveSessionHeader(llm.sessionHeader)
|
|
138
|
+
},
|
|
139
|
+
appName: options.appName,
|
|
140
|
+
getMenus: options.getMenus ?? (() => void 0),
|
|
141
|
+
routerMode,
|
|
142
|
+
getCurrentPath: options.getCurrentPath ?? (() => readLocationPath(routerMode)),
|
|
143
|
+
resolveRouteName: options.resolveRouteName,
|
|
144
|
+
routeHints: options.routeHints ?? {},
|
|
145
|
+
layoutHint: options.layoutHint,
|
|
146
|
+
beforeTask: options.beforeTask,
|
|
147
|
+
beforeTaskLabel: options.beforeTaskLabel || "任务开始前执行宿主预处理",
|
|
148
|
+
storageKeyPrefix: options.storageKeyPrefix || "tz-page-use",
|
|
149
|
+
pageAgentOptions: options.pageAgentOptions ?? {},
|
|
150
|
+
exposeGlobal: options.exposeGlobal !== false
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** URL 查询参数:?pageUseDebug=1 打开调试面板,?pageUseDebug=0 关闭;结果记入 sessionStorage */
|
|
154
|
+
const LAUNCHER_QUERY_KEY = "pageUseDebug";
|
|
155
|
+
/** 从 location 读取调试开关(hash 路由的查询参数在 # 之后,两处都查) */
|
|
156
|
+
function readLauncherQuery() {
|
|
157
|
+
const fromSearch = new URLSearchParams(window.location.search).get(LAUNCHER_QUERY_KEY);
|
|
158
|
+
if (fromSearch !== null) return fromSearch;
|
|
159
|
+
const hashQuery = window.location.hash.split("?")[1];
|
|
160
|
+
return hashQuery ? new URLSearchParams(hashQuery).get(LAUNCHER_QUERY_KEY) : null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 调试面板是否显示。优先级:URL 参数(并记入本会话)> 会话记录 > fallback。
|
|
164
|
+
* fallback 由宿主决定(例如「开发环境显示,生产环境隐藏」)。
|
|
165
|
+
*/
|
|
166
|
+
function readLauncherSwitch(fallback, storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX) {
|
|
167
|
+
if (typeof window === "undefined") return fallback;
|
|
168
|
+
const sessionKey = `${storageKeyPrefix}-debug`;
|
|
169
|
+
try {
|
|
170
|
+
const query = readLauncherQuery();
|
|
171
|
+
if (query !== null) {
|
|
172
|
+
const enabled = query === "1" || query === "true";
|
|
173
|
+
window.sessionStorage.setItem(sessionKey, enabled ? "1" : "0");
|
|
174
|
+
return enabled;
|
|
175
|
+
}
|
|
176
|
+
const session = window.sessionStorage.getItem(sessionKey);
|
|
177
|
+
if (session !== null) return session === "1";
|
|
178
|
+
} catch {}
|
|
179
|
+
return fallback;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/core/store.ts
|
|
184
|
+
function createStore(initial) {
|
|
185
|
+
let state = initial;
|
|
186
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
187
|
+
const notify = () => {
|
|
188
|
+
for (const listener of listeners) try {
|
|
189
|
+
listener(state);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.warn("[PageUse] store 订阅回调异常", err);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
return {
|
|
195
|
+
get: () => state,
|
|
196
|
+
set(patch) {
|
|
197
|
+
const partial = typeof patch === "function" ? patch(state) : patch;
|
|
198
|
+
if (Object.keys(partial).every((key) => Object.is(state[key], partial[key]))) return;
|
|
199
|
+
state = {
|
|
200
|
+
...state,
|
|
201
|
+
...partial
|
|
202
|
+
};
|
|
203
|
+
notify();
|
|
204
|
+
},
|
|
205
|
+
replace(next) {
|
|
206
|
+
if (Object.is(next, state)) return;
|
|
207
|
+
state = next;
|
|
208
|
+
notify();
|
|
209
|
+
},
|
|
210
|
+
subscribe(listener) {
|
|
211
|
+
listeners.add(listener);
|
|
212
|
+
return () => {
|
|
213
|
+
listeners.delete(listener);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/core/settings.ts
|
|
221
|
+
/**
|
|
222
|
+
* Page Use 本地设置(localStorage 持久化,单例)。
|
|
223
|
+
* 含 page-agent 内置面板/元素编号高亮开关、蒙层与光晕、演示播报、调试入口拖拽位置。
|
|
224
|
+
* 存储键为 `${storageKeyPrefix}-settings`。
|
|
225
|
+
*/
|
|
226
|
+
/** page-agent 元素高亮容器 id(page-controller 内部常量,随版本可能变化) */
|
|
227
|
+
const HIGHLIGHT_CONTAINER_ID = "playwright-highlight-container";
|
|
228
|
+
const HIGHLIGHT_STYLE_ID = "page-use-hide-highlights";
|
|
229
|
+
/** page-agent 内置面板根节点 id(@page-agent/ui 内部常量) */
|
|
230
|
+
const BUILTIN_PANEL_ID = "page-agent-runtime_agent-panel";
|
|
231
|
+
const BUILTIN_PANEL_STYLE_ID = "page-use-hide-builtin-panel";
|
|
232
|
+
const MASK_THEME_STYLE_ID = "page-use-mask-theme";
|
|
233
|
+
const DEFAULT_PAGE_USE_SETTINGS = {
|
|
234
|
+
showBuiltinPanel: false,
|
|
235
|
+
showHighlights: false,
|
|
236
|
+
showStepDetails: true,
|
|
237
|
+
enableBeforeTask: true,
|
|
238
|
+
enableMask: true,
|
|
239
|
+
customMaskEffect: true,
|
|
240
|
+
voiceEnabled: false,
|
|
241
|
+
voiceRate: 1.1,
|
|
242
|
+
voiceName: "",
|
|
243
|
+
fabPos: null
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* 调试面板不再提供开关的设置项及其固定值。
|
|
247
|
+
* 复选框已移除,本地存储中曾经改过的值无法在界面上改回,因此读取与更新时一律覆盖为固定值;
|
|
248
|
+
* PageUseSettings 保留这两个字段,避免破坏调用方的类型。
|
|
249
|
+
*/
|
|
250
|
+
const LOCKED_SETTINGS = {
|
|
251
|
+
showBuiltinPanel: false,
|
|
252
|
+
customMaskEffect: true
|
|
253
|
+
};
|
|
254
|
+
function storageKey$1(prefix) {
|
|
255
|
+
return `${prefix}-settings`;
|
|
256
|
+
}
|
|
257
|
+
function load$1(prefix) {
|
|
258
|
+
try {
|
|
259
|
+
const raw = window.localStorage.getItem(storageKey$1(prefix));
|
|
260
|
+
if (!raw) return { ...DEFAULT_PAGE_USE_SETTINGS };
|
|
261
|
+
return {
|
|
262
|
+
...DEFAULT_PAGE_USE_SETTINGS,
|
|
263
|
+
...JSON.parse(raw),
|
|
264
|
+
...LOCKED_SETTINGS
|
|
265
|
+
};
|
|
266
|
+
} catch {
|
|
267
|
+
return { ...DEFAULT_PAGE_USE_SETTINGS };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function save(prefix, settings) {
|
|
271
|
+
try {
|
|
272
|
+
window.localStorage.setItem(storageKey$1(prefix), JSON.stringify(settings));
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
/** 按开关注入/移除一段覆盖样式(用于压制 page-agent 自带的 DOM,这些节点位于宿主 document 中) */
|
|
276
|
+
function toggleOverrideStyle(styleId, css, show) {
|
|
277
|
+
const existing = document.getElementById(styleId);
|
|
278
|
+
if (show) {
|
|
279
|
+
existing?.remove();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (existing) return;
|
|
283
|
+
const style = document.createElement("style");
|
|
284
|
+
style.id = styleId;
|
|
285
|
+
style.textContent = css;
|
|
286
|
+
document.head.appendChild(style);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* 把设置同步到页面:
|
|
290
|
+
* - 高亮:page-controller 的 doHighlightElements 内部写死为 true,只能用样式覆盖其高亮容器
|
|
291
|
+
* - 内置面板:Panel 在 statuschange 到 running 时会自行 show(),因此用 !important 样式压制
|
|
292
|
+
* - 蒙层主题:注入覆盖样式;关闭时移除,恢复 page-agent 默认光晕与光标
|
|
293
|
+
*/
|
|
294
|
+
function applyToDocument(settings, prev) {
|
|
295
|
+
if (typeof document === "undefined") return;
|
|
296
|
+
if (!prev || prev.showHighlights !== settings.showHighlights) toggleOverrideStyle(HIGHLIGHT_STYLE_ID, `#${HIGHLIGHT_CONTAINER_ID}{display:none !important;}`, settings.showHighlights);
|
|
297
|
+
if (!prev || prev.showBuiltinPanel !== settings.showBuiltinPanel) toggleOverrideStyle(BUILTIN_PANEL_STYLE_ID, `#${BUILTIN_PANEL_ID}{display:none !important;}`, settings.showBuiltinPanel);
|
|
298
|
+
if (!prev || prev.customMaskEffect !== settings.customMaskEffect) toggleOverrideStyle(MASK_THEME_STYLE_ID, PAGE_USE_MASK_THEME_CSS, !settings.customMaskEffect);
|
|
299
|
+
}
|
|
300
|
+
let prefix$1 = DEFAULT_STORAGE_KEY_PREFIX;
|
|
301
|
+
let store$2 = null;
|
|
302
|
+
/** 懒创建:首次访问时读取存储并把设置应用到页面 */
|
|
303
|
+
function ensureStore$1() {
|
|
304
|
+
if (store$2) return store$2;
|
|
305
|
+
const created = createStore(load$1(prefix$1));
|
|
306
|
+
let prev = null;
|
|
307
|
+
const sync = (next) => {
|
|
308
|
+
applyToDocument(next, prev);
|
|
309
|
+
prev = next;
|
|
310
|
+
};
|
|
311
|
+
sync(created.get());
|
|
312
|
+
created.subscribe((next) => {
|
|
313
|
+
save(prefix$1, next);
|
|
314
|
+
sync(next);
|
|
315
|
+
});
|
|
316
|
+
store$2 = created;
|
|
317
|
+
return created;
|
|
318
|
+
}
|
|
319
|
+
/** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
|
|
320
|
+
function setSettingsStoragePrefix(next) {
|
|
321
|
+
if (next === prefix$1 && store$2) return;
|
|
322
|
+
prefix$1 = next;
|
|
323
|
+
if (store$2) store$2.replace(load$1(prefix$1));
|
|
324
|
+
else ensureStore$1();
|
|
325
|
+
}
|
|
326
|
+
const pageUseSettings = {
|
|
327
|
+
store: {
|
|
328
|
+
get: () => ensureStore$1().get(),
|
|
329
|
+
subscribe: (listener) => ensureStore$1().subscribe(listener)
|
|
330
|
+
},
|
|
331
|
+
get() {
|
|
332
|
+
return ensureStore$1().get();
|
|
333
|
+
},
|
|
334
|
+
/** 固定项(LOCKED_SETTINGS)的修改被忽略 */
|
|
335
|
+
update(patch) {
|
|
336
|
+
ensureStore$1().set({
|
|
337
|
+
...patch,
|
|
338
|
+
...LOCKED_SETTINGS
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
/** 把浮动入口(及随之定位的面板)恢复到默认角落位置 */
|
|
342
|
+
resetPositions() {
|
|
343
|
+
ensureStore$1().set({ fabPos: null });
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/voice/speaker.ts
|
|
349
|
+
/**
|
|
350
|
+
* 浏览器语音合成(speechSynthesis)封装:演示播报的播放队列。
|
|
351
|
+
*
|
|
352
|
+
* 队列只有两部分:当前正在播报的一组句子,以及一条待播的步骤文本。
|
|
353
|
+
* - 步骤播报不打断当前播报,只覆盖待播槽(语音跟不上执行时丢弃中间步骤);
|
|
354
|
+
* - 提问 / 结论先清空再播报;
|
|
355
|
+
* - 每次清空递增代次,旧代次的 onend / onerror / 超时回调一律忽略,避免 cancel() 后迟到的回调误触发下一条。
|
|
356
|
+
*
|
|
357
|
+
* 不支持语音合成的环境中所有方法静默返回。
|
|
358
|
+
*/
|
|
359
|
+
/** 播报语言 */
|
|
360
|
+
const SPEECH_LANG = "zh-CN";
|
|
361
|
+
/** 超时兜底的语速估算:中文语音在 rate=1 时约每秒 4 字 */
|
|
362
|
+
const CHARS_PER_SECOND = 4;
|
|
363
|
+
/** 超时兜底的余量(毫秒):覆盖语音引擎启动延迟;onend 不触发时按估算时长 + 余量视为播完 */
|
|
364
|
+
const TIMEOUT_MARGIN_MS = 2e3;
|
|
365
|
+
/** 当前环境是否支持语音合成 */
|
|
366
|
+
function isSpeechSupported() {
|
|
367
|
+
return typeof globalThis.speechSynthesis !== "undefined" && typeof globalThis.SpeechSynthesisUtterance !== "undefined";
|
|
368
|
+
}
|
|
369
|
+
/** 可用的中文语音(lang 以 zh 开头,兼容 zh-CN / zh_CN / zh-HK 等写法);Chrome 首次调用可能为空,需监听 voiceschanged */
|
|
370
|
+
function getChineseVoices() {
|
|
371
|
+
if (!isSpeechSupported()) return [];
|
|
372
|
+
return globalThis.speechSynthesis.getVoices().filter((voice) => /^zh(?:$|[-_])/i.test(voice.lang));
|
|
373
|
+
}
|
|
374
|
+
/** 语音选择:指定名称 → 第一个中文语音 → undefined(浏览器默认语音) */
|
|
375
|
+
function resolveVoice(voiceName) {
|
|
376
|
+
if (!isSpeechSupported()) return void 0;
|
|
377
|
+
const voices = globalThis.speechSynthesis.getVoices();
|
|
378
|
+
return voiceName && voices.find((voice) => voice.name === voiceName) || getChineseVoices()[0];
|
|
379
|
+
}
|
|
380
|
+
function createSpeaker(getSettings) {
|
|
381
|
+
let generation = 0;
|
|
382
|
+
/** 当前组剩余的句子;null 表示空闲 */
|
|
383
|
+
let current = null;
|
|
384
|
+
let pending = null;
|
|
385
|
+
let timer = null;
|
|
386
|
+
function clearTimer() {
|
|
387
|
+
if (timer !== null) {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
timer = null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function playGroup(sentences) {
|
|
393
|
+
current = [...sentences];
|
|
394
|
+
playNext();
|
|
395
|
+
}
|
|
396
|
+
function playNext() {
|
|
397
|
+
clearTimer();
|
|
398
|
+
const sentence = current?.shift();
|
|
399
|
+
if (sentence === void 0) {
|
|
400
|
+
current = null;
|
|
401
|
+
if (pending) {
|
|
402
|
+
const next = pending;
|
|
403
|
+
pending = null;
|
|
404
|
+
playGroup(next);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const { rate, voiceName } = getSettings();
|
|
409
|
+
const utterance = new SpeechSynthesisUtterance(sentence);
|
|
410
|
+
utterance.lang = SPEECH_LANG;
|
|
411
|
+
utterance.rate = rate > 0 ? rate : 1;
|
|
412
|
+
const voice = resolveVoice(voiceName);
|
|
413
|
+
if (voice) utterance.voice = voice;
|
|
414
|
+
const owner = generation;
|
|
415
|
+
let settled = false;
|
|
416
|
+
const finish = () => {
|
|
417
|
+
if (settled || owner !== generation) return;
|
|
418
|
+
settled = true;
|
|
419
|
+
playNext();
|
|
420
|
+
};
|
|
421
|
+
utterance.onend = finish;
|
|
422
|
+
utterance.onerror = finish;
|
|
423
|
+
timer = setTimeout(finish, sentence.length / (CHARS_PER_SECOND * utterance.rate) * 1e3 + TIMEOUT_MARGIN_MS);
|
|
424
|
+
globalThis.speechSynthesis.speak(utterance);
|
|
425
|
+
}
|
|
426
|
+
function clear() {
|
|
427
|
+
const active = current !== null;
|
|
428
|
+
generation += 1;
|
|
429
|
+
current = null;
|
|
430
|
+
pending = null;
|
|
431
|
+
clearTimer();
|
|
432
|
+
if (active && isSpeechSupported()) globalThis.speechSynthesis.cancel();
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
enqueue(sentences) {
|
|
436
|
+
if (!isSpeechSupported() || !sentences.length) return;
|
|
437
|
+
if (current !== null) {
|
|
438
|
+
pending = sentences;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
playGroup(sentences);
|
|
442
|
+
},
|
|
443
|
+
interrupt(sentences) {
|
|
444
|
+
if (!isSpeechSupported()) return;
|
|
445
|
+
clear();
|
|
446
|
+
if (sentences.length) playGroup(sentences);
|
|
447
|
+
},
|
|
448
|
+
clear
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/voice/speechText.ts
|
|
454
|
+
/**
|
|
455
|
+
* 播报文本清洗(纯函数,无 DOM 依赖):把模型输出的步骤描述 / 结论转成适合语音合成的句子数组。
|
|
456
|
+
* 结果只用于播报,不回写任务结果与面板展示内容。
|
|
457
|
+
*/
|
|
458
|
+
/** 单条播报清洗后的字数上限:中文语音约 4 字/秒,120 字约 30 秒 */
|
|
459
|
+
const SPEECH_MAX_CHARS = 120;
|
|
460
|
+
/** 超过上限截断时追加的提示语 */
|
|
461
|
+
const SPEECH_TRUNCATED_HINT = "详细内容请查看面板";
|
|
462
|
+
/**
|
|
463
|
+
* 单次合成的字数上限:Chrome 的在线语音单条超过约 15 秒会被截断,
|
|
464
|
+
* 超过该长度的句子再按逗号等停顿切分(约 4 字/秒 × 12 秒)
|
|
465
|
+
*/
|
|
466
|
+
const MAX_UTTERANCE_CHARS = 50;
|
|
467
|
+
/** 句末标点:截断与切句的边界(分号视为句间边界,列表项以分号连接) */
|
|
468
|
+
const SENTENCE_END = "。!?!?;;";
|
|
469
|
+
/** 句内停顿:长句二次切分的边界 */
|
|
470
|
+
const CLAUSE_END = ",、:,:";
|
|
471
|
+
/** 行尾已有这些标点时,拼接下一行不再补分隔符 */
|
|
472
|
+
const TRAILING_PUNCT = /[。!?!?;;,,、::]$/;
|
|
473
|
+
/**
|
|
474
|
+
* 元素编号:page-agent 以 [index] 标注可交互元素,模型常把编号写进步骤描述,
|
|
475
|
+
* 如「点击索引为 23 的菜单」「点击 [23]」「点击元素 23」。
|
|
476
|
+
* 不匹配「编号 / 序号」:结论中的「编号 20260809」等可能是业务数据。
|
|
477
|
+
*/
|
|
478
|
+
const ELEMENT_INDEX_PATTERNS = [
|
|
479
|
+
/索引\s*(?:为|是)?\s*\[?\d+\]?\s*(?:号)?\s*(?:的)?/g,
|
|
480
|
+
/元素\s*\[\d+\]\s*(?:的)?/g,
|
|
481
|
+
/元素\s*\d+\s*(?:号)?\s*(?:的)?/g,
|
|
482
|
+
/\[\d+\]/g
|
|
483
|
+
];
|
|
484
|
+
/** 表格分隔行:| --- | :---: | */
|
|
485
|
+
const TABLE_SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)*\|?\s*$/;
|
|
486
|
+
/** 表格数据行:首尾为竖线 */
|
|
487
|
+
const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
|
488
|
+
/** 列表项行首:- * + 或 1. 1) 1、(序号后紧跟数字时视为小数,如「2.5 吨」,不当作列表) */
|
|
489
|
+
const LIST_MARKER = /^\s*(?:[-*+]\s+|\d+[.)](?!\d)\s*|\d+、\s*)/;
|
|
490
|
+
/** 行内 Markdown、网址、emoji、元素编号等清理 */
|
|
491
|
+
function cleanInline(text) {
|
|
492
|
+
let s = text;
|
|
493
|
+
for (const pattern of ELEMENT_INDEX_PATTERNS) s = s.replace(pattern, "");
|
|
494
|
+
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/https?:\/\/[^\s))]+/g, "").replace(/(\*\*|__)(.+?)\1/g, "$2").replace(/\*([^*\n]+)\*/g, "$1").replace(/~~(.+?)~~/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/[*`~]/g, "").replace(/[\p{Extended_Pictographic}\u{FE0F}\u{200D}\u{2713}\u{2717}\u{2718}]/gu, "").replace(/[「」『』【】]/g, "");
|
|
495
|
+
return s.replace(/\s+/g, " ").trim();
|
|
496
|
+
}
|
|
497
|
+
/** 按行处理块级结构:代码块与 HTML 已在调用前移除 */
|
|
498
|
+
function toLines(text) {
|
|
499
|
+
const lines = [];
|
|
500
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
501
|
+
if (TABLE_SEPARATOR.test(raw)) continue;
|
|
502
|
+
if (TABLE_ROW.test(raw)) {
|
|
503
|
+
const cells = raw.split("|").map((cell) => cleanInline(cell)).filter(Boolean);
|
|
504
|
+
if (cells.length) lines.push({
|
|
505
|
+
text: cells.join(","),
|
|
506
|
+
item: true
|
|
507
|
+
});
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const item = LIST_MARKER.test(raw);
|
|
511
|
+
const cleaned = cleanInline(raw.replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:>\s?)+/, "").replace(LIST_MARKER, ""));
|
|
512
|
+
if (cleaned) lines.push({
|
|
513
|
+
text: cleaned,
|
|
514
|
+
item
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return lines;
|
|
518
|
+
}
|
|
519
|
+
/** 拼接各行:列表项之间用分号,其余行之间补句号;行尾已有标点时不补 */
|
|
520
|
+
function joinLines(lines) {
|
|
521
|
+
let result = "";
|
|
522
|
+
lines.forEach((line, index) => {
|
|
523
|
+
if (index > 0 && !TRAILING_PUNCT.test(result)) result += lines[index - 1].item && line.item ? ";" : "。";
|
|
524
|
+
result += line.text;
|
|
525
|
+
});
|
|
526
|
+
return result.replace(/([。!?!?;;,,、::])[。!?!?;;,,、::\s]+/g, "$1").replace(/^[。!?!?;;,,、::\s]+/, "").trim();
|
|
527
|
+
}
|
|
528
|
+
/** 超过上限时在上限内最后一个句末标点处截断,找不到则硬截断,并追加提示语 */
|
|
529
|
+
function truncate(text, maxChars) {
|
|
530
|
+
if (text.length <= maxChars) return text;
|
|
531
|
+
const head = text.slice(0, maxChars);
|
|
532
|
+
let cut = -1;
|
|
533
|
+
for (let i = head.length - 1; i >= 0; i -= 1) if (SENTENCE_END.includes(head[i])) {
|
|
534
|
+
cut = i;
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
let kept = cut > 0 ? head.slice(0, cut + 1) : head;
|
|
538
|
+
kept = kept.replace(/[;;,,、::]$/, "");
|
|
539
|
+
if (!/[。!?!?]$/.test(kept)) kept += "。";
|
|
540
|
+
return `${kept}${SPEECH_TRUNCATED_HINT}`;
|
|
541
|
+
}
|
|
542
|
+
/** 按句末标点切句;过长的句子再按逗号等停顿切分 */
|
|
543
|
+
function splitSentences(text) {
|
|
544
|
+
const sentences = text.match(new RegExp(`[^${SENTENCE_END}]+[${SENTENCE_END}]*`, "g")) || [];
|
|
545
|
+
const result = [];
|
|
546
|
+
for (const sentence of sentences) {
|
|
547
|
+
if (sentence.length <= MAX_UTTERANCE_CHARS) {
|
|
548
|
+
result.push(sentence);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const clauses = sentence.match(new RegExp(`[^${CLAUSE_END}]+[${CLAUSE_END}]*`, "g")) || [sentence];
|
|
552
|
+
let buffer = "";
|
|
553
|
+
for (const clause of clauses) {
|
|
554
|
+
if (buffer && buffer.length + clause.length > MAX_UTTERANCE_CHARS) {
|
|
555
|
+
result.push(buffer);
|
|
556
|
+
buffer = "";
|
|
557
|
+
}
|
|
558
|
+
buffer += clause;
|
|
559
|
+
}
|
|
560
|
+
if (buffer) result.push(buffer);
|
|
561
|
+
}
|
|
562
|
+
return result.map((s) => s.trim()).filter((s) => /[\p{L}\p{N}]/u.test(s));
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* 模型文本 → 播报句子数组。清洗后为空时返回空数组(调用方据此跳过播报)。
|
|
566
|
+
* 处理顺序:代码块 / HTML → 表格 → 行内 Markdown 与符号 → 拼行 → 截断 → 切句。
|
|
567
|
+
*/
|
|
568
|
+
function toSpeechText(text, options = {}) {
|
|
569
|
+
const maxChars = options.maxChars ?? 120;
|
|
570
|
+
const joined = joinLines(toLines((text || "").replace(/```[\s\S]*?(?:```|$)/g, "\n").replace(/<\/?[a-zA-Z][^>]*>/g, "")));
|
|
571
|
+
if (!joined) return [];
|
|
572
|
+
return splitSentences(truncate(joined, maxChars));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/voice/narration.ts
|
|
577
|
+
/**
|
|
578
|
+
* 演示播报:订阅进度事件,按设置把步骤描述、提问与结论交给 speaker 播报。
|
|
579
|
+
* 只订阅事件,不影响任务执行节奏;对所有任务来源生效。
|
|
580
|
+
*/
|
|
581
|
+
/**
|
|
582
|
+
* 不播报的步骤:滚动、等待没有演示价值;done 由结论播报替代;
|
|
583
|
+
* ask_user 的问题由 question 事件播报;error / retry 为执行出错与重试的伪工具名。
|
|
584
|
+
*/
|
|
585
|
+
const SILENT_STEP_TOOLS = /* @__PURE__ */ new Set([
|
|
586
|
+
"scroll",
|
|
587
|
+
"scroll_horizontally",
|
|
588
|
+
"wait",
|
|
589
|
+
"done",
|
|
590
|
+
"ask_user",
|
|
591
|
+
"error",
|
|
592
|
+
"retry"
|
|
593
|
+
]);
|
|
594
|
+
/** 启动播报,返回停止函数(取消订阅并停止播报) */
|
|
595
|
+
function startNarration({ subscribe, speaker: injected }) {
|
|
596
|
+
const speaker = injected || createSpeaker(() => {
|
|
597
|
+
const { voiceRate, voiceName } = pageUseSettings.get();
|
|
598
|
+
return {
|
|
599
|
+
rate: voiceRate,
|
|
600
|
+
voiceName
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
const enabled = () => pageUseSettings.get().voiceEnabled;
|
|
604
|
+
const offEvents = subscribe((event) => {
|
|
605
|
+
switch (event.type) {
|
|
606
|
+
case "started":
|
|
607
|
+
speaker.clear();
|
|
608
|
+
break;
|
|
609
|
+
case "step":
|
|
610
|
+
if (!enabled() || SILENT_STEP_TOOLS.has(event.step.tool)) return;
|
|
611
|
+
speaker.enqueue(toSpeechText(event.step.goal));
|
|
612
|
+
break;
|
|
613
|
+
case "question":
|
|
614
|
+
if (enabled()) speaker.interrupt(toSpeechText(event.question));
|
|
615
|
+
break;
|
|
616
|
+
case "finished": if (event.result.status === "stopped" || !enabled()) speaker.clear();
|
|
617
|
+
else speaker.interrupt(toSpeechText(event.result.summary));
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
let wasEnabled = enabled();
|
|
621
|
+
const offSettings = pageUseSettings.store.subscribe((settings) => {
|
|
622
|
+
if (wasEnabled && !settings.voiceEnabled) speaker.clear();
|
|
623
|
+
wasEnabled = settings.voiceEnabled;
|
|
624
|
+
});
|
|
625
|
+
return () => {
|
|
626
|
+
offEvents();
|
|
627
|
+
offSettings();
|
|
628
|
+
speaker.clear();
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
//#endregion
|
|
5
633
|
//#region src/core/siteMap.ts
|
|
6
634
|
/** 去掉 `#`、前导 `/` 与查询串,得到用于比较的路径 */
|
|
7
635
|
function normalizePath(path) {
|
|
@@ -52,8 +680,17 @@ function findMenuByPath(path, menus) {
|
|
|
52
680
|
//#region src/core/pageInstructions.ts
|
|
53
681
|
/** 未提供 layoutHint 时的通用页面结构描述 */
|
|
54
682
|
const DEFAULT_LAYOUT_HINT = ["- 页面通常由导航菜单与内容区组成;内容区顶部若有面包屑或标题,可用来确认当前位置。", "- 分组菜单点击后展开/收起下级;叶子菜单点击后进入页面。"].join("\n");
|
|
55
|
-
/**
|
|
56
|
-
|
|
683
|
+
/**
|
|
684
|
+
* 演示播报开启时追加的约束:page-agent 默认要求 next_goal 写「目标 + 动作」,模型常带元素编号,
|
|
685
|
+
* 念出来生硬;这里只约束步骤描述,不改变 done 的 text(结论会回传给调用方)。
|
|
686
|
+
*/
|
|
687
|
+
const NARRATION_RULES = [
|
|
688
|
+
"## 语音播报",
|
|
689
|
+
"- 每一步的 next_goal 会被语音播报给观众:写成一句简短的中文,描述这一步要做什么,如「打开隐患台账页面」;不要包含元素编号、索引或方括号数字。",
|
|
690
|
+
"- 该要求只针对 next_goal;done 的 text 仍按原要求书写。"
|
|
691
|
+
].join("\n");
|
|
692
|
+
/** 全局系统提示:站点结构 + 交互约定 + 安全边界(+ 播报约束) */
|
|
693
|
+
function buildSystemInstruction(options, flags = {}) {
|
|
57
694
|
const urlLabel = options.routerMode === "hash" ? "URL hash" : "URL 路径";
|
|
58
695
|
const siteMap = buildSiteMapText(options.getMenus(), options.routerMode);
|
|
59
696
|
return [
|
|
@@ -76,7 +713,8 @@ function buildSystemInstruction(options) {
|
|
|
76
713
|
"",
|
|
77
714
|
"## 安全边界",
|
|
78
715
|
"- 未经用户明确要求,不得执行删除、提交、审批、发布、批量修改等不可逆操作;不确定时用 ask_user 询问。",
|
|
79
|
-
"- 任务完成后调用 done,用一句话说明当前所在页面及已完成的动作;无法完成时说明卡在哪一步。"
|
|
716
|
+
"- 任务完成后调用 done,用一句话说明当前所在页面及已完成的动作;无法完成时说明卡在哪一步。",
|
|
717
|
+
...flags.narration ? ["", NARRATION_RULES] : []
|
|
80
718
|
].join("\n");
|
|
81
719
|
}
|
|
82
720
|
/**
|
|
@@ -115,7 +753,7 @@ const STEP_DELAY_SECONDS = .6;
|
|
|
115
753
|
function newSessionId() {
|
|
116
754
|
return typeof crypto !== "undefined" && crypto.randomUUID?.() || `page-use-${Date.now()}`;
|
|
117
755
|
}
|
|
118
|
-
function createPageUseAgent({ options, enableMask, shouldRunBeforeTask, onTaskFinished }) {
|
|
756
|
+
function createPageUseAgent({ options, enableMask, narration = false, shouldRunBeforeTask, onTaskFinished }) {
|
|
119
757
|
const { llm } = options;
|
|
120
758
|
if (!llm.baseURL) throw new Error("[PageUse] 未配置 llm.baseURL");
|
|
121
759
|
let sessionId = newSessionId();
|
|
@@ -150,7 +788,7 @@ function createPageUseAgent({ options, enableMask, shouldRunBeforeTask, onTaskFi
|
|
|
150
788
|
enableMask,
|
|
151
789
|
experimentalScriptExecutionTool: false,
|
|
152
790
|
instructions: {
|
|
153
|
-
system: buildSystemInstruction(options),
|
|
791
|
+
system: buildSystemInstruction(options, { narration }),
|
|
154
792
|
getPageInstructions: () => buildPageInstruction(options)
|
|
155
793
|
},
|
|
156
794
|
transformPageContent: (content) => {
|
|
@@ -162,139 +800,16 @@ function createPageUseAgent({ options, enableMask, shouldRunBeforeTask, onTaskFi
|
|
|
162
800
|
sessionId = newSessionId();
|
|
163
801
|
if (hostBeforeTask && shouldRunBeforeTask()) try {
|
|
164
802
|
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();
|
|
803
|
+
} catch (err) {
|
|
804
|
+
console.warn("[PageUse] beforeTask 执行异常", err);
|
|
805
|
+
}
|
|
806
|
+
await options.pageAgentOptions.onBeforeTask?.(...args);
|
|
290
807
|
},
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
listeners.delete(listener);
|
|
295
|
-
};
|
|
808
|
+
onAfterTask: async (...args) => {
|
|
809
|
+
onTaskFinished?.(args[1]);
|
|
810
|
+
await options.pageAgentOptions.onAfterTask?.(...args);
|
|
296
811
|
}
|
|
297
|
-
};
|
|
812
|
+
});
|
|
298
813
|
}
|
|
299
814
|
|
|
300
815
|
//#endregion
|
|
@@ -304,262 +819,59 @@ function createStore(initial) {
|
|
|
304
819
|
* 用于快速重放上次指令,以及查看任务结果。存储键为 `${storageKeyPrefix}-history`。
|
|
305
820
|
*/
|
|
306
821
|
const MAX_ENTRIES = 30;
|
|
307
|
-
function storageKey
|
|
822
|
+
function storageKey(prefix) {
|
|
308
823
|
return `${prefix}-history`;
|
|
309
824
|
}
|
|
310
|
-
function load
|
|
825
|
+
function load(prefix) {
|
|
311
826
|
try {
|
|
312
|
-
const raw = window.localStorage.getItem(storageKey
|
|
827
|
+
const raw = window.localStorage.getItem(storageKey(prefix));
|
|
313
828
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
314
829
|
return Array.isArray(parsed) ? parsed : [];
|
|
315
830
|
} catch {
|
|
316
831
|
return [];
|
|
317
832
|
}
|
|
318
833
|
}
|
|
319
|
-
let prefix
|
|
834
|
+
let prefix = DEFAULT_STORAGE_KEY_PREFIX;
|
|
320
835
|
/** 懒创建:避免模块加载即访问 window(SSR / 测试环境) */
|
|
321
|
-
let store$
|
|
322
|
-
function ensureStore
|
|
323
|
-
if (!store$
|
|
324
|
-
return store$
|
|
836
|
+
let store$1 = null;
|
|
837
|
+
function ensureStore() {
|
|
838
|
+
if (!store$1) store$1 = createStore({ entries: load(prefix) });
|
|
839
|
+
return store$1;
|
|
325
840
|
}
|
|
326
841
|
function persist(entries) {
|
|
327
|
-
ensureStore
|
|
842
|
+
ensureStore().set({ entries });
|
|
328
843
|
try {
|
|
329
|
-
window.localStorage.setItem(storageKey
|
|
844
|
+
window.localStorage.setItem(storageKey(prefix), JSON.stringify(entries));
|
|
330
845
|
} catch {}
|
|
331
846
|
}
|
|
332
847
|
/** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
|
|
333
848
|
function setHistoryStoragePrefix(next) {
|
|
334
|
-
if (next === prefix
|
|
335
|
-
prefix
|
|
336
|
-
ensureStore
|
|
849
|
+
if (next === prefix && store$1) return;
|
|
850
|
+
prefix = next;
|
|
851
|
+
ensureStore().replace({ entries: load(prefix) });
|
|
337
852
|
}
|
|
338
853
|
const pageUseHistory = {
|
|
339
854
|
store: {
|
|
340
|
-
get: () => ensureStore
|
|
341
|
-
subscribe: (listener) => ensureStore
|
|
855
|
+
get: () => ensureStore().get(),
|
|
856
|
+
subscribe: (listener) => ensureStore().subscribe(listener)
|
|
342
857
|
},
|
|
343
858
|
entries() {
|
|
344
|
-
return ensureStore
|
|
859
|
+
return ensureStore().get().entries;
|
|
345
860
|
},
|
|
346
861
|
add(entry) {
|
|
347
862
|
persist([{
|
|
348
863
|
id: `${entry.at}-${Math.random().toString(36).slice(2, 8)}`,
|
|
349
864
|
...entry
|
|
350
|
-
}, ...ensureStore
|
|
865
|
+
}, ...ensureStore().get().entries].slice(0, MAX_ENTRIES));
|
|
351
866
|
},
|
|
352
867
|
remove(id) {
|
|
353
|
-
persist(ensureStore
|
|
868
|
+
persist(ensureStore().get().entries.filter((item) => item.id !== id));
|
|
354
869
|
},
|
|
355
870
|
clear() {
|
|
356
871
|
persist([]);
|
|
357
872
|
}
|
|
358
873
|
};
|
|
359
874
|
|
|
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
875
|
//#endregion
|
|
564
876
|
//#region src/core/stepLog.ts
|
|
565
877
|
function summarizeActionInput(name, input) {
|
|
@@ -668,6 +980,10 @@ let everConfigured = false;
|
|
|
668
980
|
let agent = null;
|
|
669
981
|
/** 创建实例时的蒙层设置;与当前设置不一致时须重建 */
|
|
670
982
|
let agentMaskFlag = null;
|
|
983
|
+
/** 创建实例时的播报设置:系统提示在构造时生成,播报开关变化后须重建才能追加 / 移除步骤描述约束 */
|
|
984
|
+
let agentVoiceFlag = null;
|
|
985
|
+
/** 演示播报的停止函数;配置期间运行,解绑 / 重新配置时停止 */
|
|
986
|
+
let stopNarration = null;
|
|
671
987
|
/** 已挂载且可作答的调试面板数量;>0 时未指定 onAskUser 的提问交给面板 */
|
|
672
988
|
let questionUiCount = 0;
|
|
673
989
|
/** 当前任务的调用方选项(提问回调、进度回调) */
|
|
@@ -752,20 +1068,25 @@ function handleAskUser(question, signal) {
|
|
|
752
1068
|
}, { once: true });
|
|
753
1069
|
});
|
|
754
1070
|
}
|
|
755
|
-
/**
|
|
1071
|
+
/**
|
|
1072
|
+
* 懒创建单例;蒙层或播报设置变化时重建(enableMask 属构造期选项,系统提示在构造时生成)。
|
|
1073
|
+
* 在任务开始时调用,因此执行中切换开关只影响下一次任务。
|
|
1074
|
+
*/
|
|
756
1075
|
function ensureAgent(options) {
|
|
757
1076
|
const settings = pageUseSettings.get();
|
|
758
|
-
if (agent && !agent.disposed && agentMaskFlag === settings.enableMask) return agent;
|
|
1077
|
+
if (agent && !agent.disposed && agentMaskFlag === settings.enableMask && agentVoiceFlag === settings.voiceEnabled) return agent;
|
|
759
1078
|
if (agent && !agent.disposed) agent.dispose();
|
|
760
1079
|
const created = createPageUseAgent({
|
|
761
1080
|
options,
|
|
762
1081
|
enableMask: settings.enableMask,
|
|
1082
|
+
narration: settings.voiceEnabled,
|
|
763
1083
|
shouldRunBeforeTask: () => pageUseSettings.get().enableBeforeTask
|
|
764
1084
|
});
|
|
765
1085
|
bindAgentEvents(created);
|
|
766
1086
|
created.onAskUser = (question, askOptions) => handleAskUser(question, askOptions?.signal);
|
|
767
1087
|
agent = created;
|
|
768
1088
|
agentMaskFlag = settings.enableMask;
|
|
1089
|
+
agentVoiceFlag = settings.voiceEnabled;
|
|
769
1090
|
store.set({ maskEnabled: settings.enableMask });
|
|
770
1091
|
return created;
|
|
771
1092
|
}
|
|
@@ -804,9 +1125,13 @@ function configurePageUse(options) {
|
|
|
804
1125
|
setSettingsStoragePrefix(options.storageKeyPrefix);
|
|
805
1126
|
setHistoryStoragePrefix(options.storageKeyPrefix);
|
|
806
1127
|
store.set({ ready: Boolean(options.llm.baseURL) });
|
|
1128
|
+
stopNarration?.();
|
|
1129
|
+
stopNarration = startNarration({ subscribe: subscribePageUse });
|
|
807
1130
|
return () => {
|
|
808
1131
|
if (config !== options) return;
|
|
809
1132
|
config = null;
|
|
1133
|
+
stopNarration?.();
|
|
1134
|
+
stopNarration = null;
|
|
810
1135
|
disposeAgent();
|
|
811
1136
|
store.set({ ready: false });
|
|
812
1137
|
};
|
|
@@ -969,6 +1294,7 @@ function disposeAgent() {
|
|
|
969
1294
|
agent?.dispose();
|
|
970
1295
|
agent = null;
|
|
971
1296
|
agentMaskFlag = null;
|
|
1297
|
+
agentVoiceFlag = null;
|
|
972
1298
|
activeOptions = null;
|
|
973
1299
|
store.set({
|
|
974
1300
|
maskEnabled: false,
|
|
@@ -1974,6 +2300,36 @@ const launcherStyles = css`
|
|
|
1974
2300
|
background: var(--bg-soft);
|
|
1975
2301
|
cursor: not-allowed;
|
|
1976
2302
|
}
|
|
2303
|
+
/* ---------- 演示播报设置 ---------- */
|
|
2304
|
+
.voice-warning {
|
|
2305
|
+
margin-left: 21px;
|
|
2306
|
+
font-size: 12px;
|
|
2307
|
+
}
|
|
2308
|
+
/* 与复选框文字左对齐(复选框宽 13px + 间距 8px) */
|
|
2309
|
+
.voice-options {
|
|
2310
|
+
display: flex;
|
|
2311
|
+
flex-direction: column;
|
|
2312
|
+
gap: 8px;
|
|
2313
|
+
margin-left: 21px;
|
|
2314
|
+
}
|
|
2315
|
+
.field {
|
|
2316
|
+
display: flex;
|
|
2317
|
+
flex-direction: column;
|
|
2318
|
+
gap: 4px;
|
|
2319
|
+
}
|
|
2320
|
+
.field-label {
|
|
2321
|
+
color: var(--text-3);
|
|
2322
|
+
font-size: 12px;
|
|
2323
|
+
}
|
|
2324
|
+
.range {
|
|
2325
|
+
width: 100%;
|
|
2326
|
+
margin: 0;
|
|
2327
|
+
accent-color: var(--primary);
|
|
2328
|
+
}
|
|
2329
|
+
select.input {
|
|
2330
|
+
height: 28px;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
1977
2333
|
.input-group {
|
|
1978
2334
|
display: flex;
|
|
1979
2335
|
gap: 6px;
|
|
@@ -2051,7 +2407,7 @@ const launcherStyles = css`
|
|
|
2051
2407
|
/**
|
|
2052
2408
|
* <tz-page-use-launcher>:调试入口(可拖拽浮动按钮 + 指令面板)。
|
|
2053
2409
|
* 与其它智能体共用 core/service 的单例状态:其它智能体下达的任务同样在此展示,可随时终止。
|
|
2054
|
-
*
|
|
2410
|
+
* page-agent 自带面板固定隐藏、元素编号高亮默认隐藏(设置里可开回),进度与提问改由本面板承接。
|
|
2055
2411
|
*
|
|
2056
2412
|
* 宿主元素须带 data-page-agent-ignore="true"(由 mountPageUseUI 设置):
|
|
2057
2413
|
* page-controller 在该属性上直接跳过整棵子树(含 shadow root),面板不会被模型当作页面元素。
|
|
@@ -2078,6 +2434,10 @@ const PANEL_HEIGHT = 560;
|
|
|
2078
2434
|
/** 日志区距底部小于该值(px)视为贴底 */
|
|
2079
2435
|
const STICK_THRESHOLD = 24;
|
|
2080
2436
|
const TEXTAREA_MIN_ROWS = 2;
|
|
2437
|
+
/** 播报语速调节范围与步长(SpeechSynthesisUtterance.rate;1 为正常语速,过快或过慢时中文语音难以听清) */
|
|
2438
|
+
const VOICE_RATE_MIN = .5;
|
|
2439
|
+
const VOICE_RATE_MAX = 2;
|
|
2440
|
+
const VOICE_RATE_STEP = .05;
|
|
2081
2441
|
/**
|
|
2082
2442
|
* 步骤 → 时间线节点颜色:失败/报错为 error,完成为 success,
|
|
2083
2443
|
* 向用户提问与重试为 warning,其余普通操作为 info(主色)。
|
|
@@ -2185,7 +2545,8 @@ var PageUseLauncherElement = class extends LitElement {
|
|
|
2185
2545
|
pageUseStore.subscribe((state) => this.onStateChange(state)),
|
|
2186
2546
|
pageUseSettings.store.subscribe(() => this.requestUpdate()),
|
|
2187
2547
|
pageUseHistory.store.subscribe(() => this.requestUpdate()),
|
|
2188
|
-
registerQuestionUi()
|
|
2548
|
+
registerQuestionUi(),
|
|
2549
|
+
this.watchVoices()
|
|
2189
2550
|
];
|
|
2190
2551
|
window.addEventListener("resize", this.onResize);
|
|
2191
2552
|
}
|
|
@@ -2195,6 +2556,14 @@ var PageUseLauncherElement = class extends LitElement {
|
|
|
2195
2556
|
this.cleanups = [];
|
|
2196
2557
|
window.removeEventListener("resize", this.onResize);
|
|
2197
2558
|
}
|
|
2559
|
+
/** Chrome 的语音列表异步加载,首次读取可能为空:加载完成后刷新语音下拉框 */
|
|
2560
|
+
watchVoices() {
|
|
2561
|
+
if (!isSpeechSupported()) return () => {};
|
|
2562
|
+
const synth = globalThis.speechSynthesis;
|
|
2563
|
+
const onChange = () => this.requestUpdate();
|
|
2564
|
+
synth.addEventListener?.("voiceschanged", onChange);
|
|
2565
|
+
return () => synth.removeEventListener?.("voiceschanged", onChange);
|
|
2566
|
+
}
|
|
2198
2567
|
firstUpdated() {
|
|
2199
2568
|
this.measureFab();
|
|
2200
2569
|
}
|
|
@@ -2434,12 +2803,11 @@ var PageUseLauncherElement = class extends LitElement {
|
|
|
2434
2803
|
const config = getPageUseConfig();
|
|
2435
2804
|
return html`<div class="fill">
|
|
2436
2805
|
<div class="settings-body">
|
|
2437
|
-
${this.renderCheckbox("showBuiltinPanel", "显示 page-agent 自带面板", "关闭后由本面板展示进度")}
|
|
2438
2806
|
${this.renderCheckbox("showHighlights", "显示元素编号高亮", "运行时页面上带数字的彩色框")}
|
|
2439
2807
|
${this.renderCheckbox("enableMask", "运行时蒙层防误触", "下次任务开始生效")}
|
|
2440
|
-
${this.renderCheckbox("customMaskEffect", "定制光晕与光标配色", "关闭后恢复 page-agent 默认效果", !settings.enableMask)}
|
|
2441
2808
|
${config?.beforeTask ? this.renderCheckbox("enableBeforeTask", config.beforeTaskLabel) : nothing}
|
|
2442
2809
|
${this.renderCheckbox("showStepDetails", "步骤显示模型思考与返回")}
|
|
2810
|
+
${this.renderVoiceSettings(settings)}
|
|
2443
2811
|
<div class="row">
|
|
2444
2812
|
<button type="button" class="btn" @click=${() => pageUseSettings.resetPositions()}>
|
|
2445
2813
|
复位窗口
|
|
@@ -2449,6 +2817,42 @@ var PageUseLauncherElement = class extends LitElement {
|
|
|
2449
2817
|
<span class="meta">端点 ${this.endpointLabel()} · 模型 ${config?.llm.model ?? "-"}</span>
|
|
2450
2818
|
</div>
|
|
2451
2819
|
</div>`;
|
|
2820
|
+
}
|
|
2821
|
+
/** 演示播报:开关 + 兼容提示;开启后显示语速与中文语音选择 */
|
|
2822
|
+
renderVoiceSettings(settings) {
|
|
2823
|
+
const supported = isSpeechSupported();
|
|
2824
|
+
const voices = getChineseVoices();
|
|
2825
|
+
const warning = !supported ? "当前浏览器不支持语音合成,开启后不会播报" : voices.length ? "" : "未检测到中文语音,将使用浏览器默认语音播报";
|
|
2826
|
+
return html`
|
|
2827
|
+
${this.renderCheckbox("voiceEnabled", "语音播报", "演示时播报执行步骤、提问与结论")}
|
|
2828
|
+
${warning ? html`<div class="alert warning voice-warning">${warning}</div>` : nothing}
|
|
2829
|
+
${settings.voiceEnabled && supported ? html`<div class="voice-options">
|
|
2830
|
+
<label class="field">
|
|
2831
|
+
<span class="field-label">语速 ${settings.voiceRate.toFixed(2)}</span>
|
|
2832
|
+
<input
|
|
2833
|
+
class="range"
|
|
2834
|
+
type="range"
|
|
2835
|
+
min=${VOICE_RATE_MIN}
|
|
2836
|
+
max=${VOICE_RATE_MAX}
|
|
2837
|
+
step=${VOICE_RATE_STEP}
|
|
2838
|
+
.value=${String(settings.voiceRate)}
|
|
2839
|
+
@input=${(e) => pageUseSettings.update({ voiceRate: Number(e.target.value) })}
|
|
2840
|
+
/>
|
|
2841
|
+
</label>
|
|
2842
|
+
<label class="field">
|
|
2843
|
+
<span class="field-label">语音</span>
|
|
2844
|
+
<select
|
|
2845
|
+
class="input"
|
|
2846
|
+
@change=${(e) => pageUseSettings.update({ voiceName: e.target.value })}
|
|
2847
|
+
>
|
|
2848
|
+
<option value="" ?selected=${!settings.voiceName}>自动(第一个中文语音)</option>
|
|
2849
|
+
${voices.map((voice) => html`<option value=${voice.name} ?selected=${voice.name === settings.voiceName}>
|
|
2850
|
+
${voice.name}
|
|
2851
|
+
</option>`)}
|
|
2852
|
+
</select>
|
|
2853
|
+
</label>
|
|
2854
|
+
</div>` : nothing}
|
|
2855
|
+
`;
|
|
2452
2856
|
}
|
|
2453
2857
|
renderLog(state, isLogEmpty) {
|
|
2454
2858
|
const running = isRunning(state);
|
|
@@ -2690,4 +3094,4 @@ function mountPageUseUI(options = {}) {
|
|
|
2690
3094
|
}
|
|
2691
3095
|
|
|
2692
3096
|
//#endregion
|
|
2693
|
-
export {
|
|
3097
|
+
export { DEFAULT_STORAGE_KEY_PREFIX as A, pageUseStore as C, DEFAULT_MAX_STEPS as D, pageUseSettings as E, readLauncherSwitch as M, PAGE_USE_MASK_COLORS as N, DEFAULT_MODEL as O, SIMULATOR_MASK_ID as P, isRunning as S, DEFAULT_PAGE_USE_SETTINGS 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, normalizeOptions as j, DEFAULT_SESSION_HEADER 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, pageUseHistory as w, isPageUseConfigured as x, getElapsedSeconds as y };
|