@opentiny/next-sdk 0.2.6 → 0.2.7
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/agent/utils/generateReActPrompt.ts +36 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.es.dev.js +758 -105
- package/dist/index.es.js +10512 -9971
- package/dist/index.js +1607 -1468
- package/dist/index.umd.dev.js +758 -105
- package/dist/index.umd.js +71 -69
- package/dist/page-tool-bridge.d.ts +132 -0
- package/dist/remoter/createRemoter.d.ts +5 -5
- package/dist/skills/index.d.ts +5 -0
- package/dist/webagent.dev.js +555 -92
- package/dist/webagent.es.dev.js +555 -92
- package/dist/webagent.es.js +10774 -10369
- package/dist/webagent.js +76 -74
- package/index.ts +3 -0
- package/package.json +1 -1
- package/page-tool-bridge.ts +441 -0
- package/remoter/createRemoter.ts +33 -26
- package/skills/index.ts +44 -14
package/index.ts
CHANGED
|
@@ -39,6 +39,9 @@ export { getAISDKTools } from './agent/utils/getAISDKTools'
|
|
|
39
39
|
export { QrCode, type QrCodeOption } from './remoter/QrCode'
|
|
40
40
|
export type * from './agent/type'
|
|
41
41
|
|
|
42
|
+
// Web MCP 页面工具桥接:工具调用自动导航 + 页面消息通信
|
|
43
|
+
export * from './page-tool-bridge'
|
|
44
|
+
|
|
42
45
|
// Web 端 Skill 公共能力:解析 skill 文档、生成 systemPrompt、内置 list_skills / get_skill_content 工具
|
|
43
46
|
export {
|
|
44
47
|
getSkillOverviews,
|
package/package.json
CHANGED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* page-tool-bridge - Web MCP 页面工具桥接模块(框架无关)
|
|
3
|
+
*
|
|
4
|
+
* 解决 Web-MCP 工具动态加载问题:工具定义(mcp-servers/)不直接写业务逻辑,
|
|
5
|
+
* 而是通过 window.postMessage 将调用转发给目标页面,页面处理后返回结果。
|
|
6
|
+
*
|
|
7
|
+
* 核心 API:
|
|
8
|
+
* - setNavigator(fn) 在应用入口注册导航函数
|
|
9
|
+
* - withPageTools(server)
|
|
10
|
+
* 包装 WebMcpServer,让 registerTool 第三个参数
|
|
11
|
+
* 同时支持原始回调函数和路由配置对象(RouteConfig)
|
|
12
|
+
* - registerPageTool() 在目标页面激活工具处理器,返回 cleanup 函数
|
|
13
|
+
*
|
|
14
|
+
* 使用方式:
|
|
15
|
+
* // mcp-servers/index.ts
|
|
16
|
+
* const server = withPageTools(new WebMcpServer())
|
|
17
|
+
*
|
|
18
|
+
* // mcp-servers/product-guide/tools.ts
|
|
19
|
+
* server.registerTool('product-guide', { title, description, inputSchema },
|
|
20
|
+
* { route: '/comprehensive', timeout: 15000 } // ← 路由配置:route 必填,timeout 可选(ms,默认 30000)
|
|
21
|
+
* )
|
|
22
|
+
* // 或仍然使用普通回调(完全兼容)
|
|
23
|
+
* server.registerTool('simple-tool', { ... }, async (input) => { ... })
|
|
24
|
+
*
|
|
25
|
+
* // 目标页面(Vue)— route 可省略,默认取 window.location.pathname
|
|
26
|
+
* onMounted(() => { cleanup = registerPageTool({ handlers }) })
|
|
27
|
+
* onUnmounted(() => cleanup())
|
|
28
|
+
*
|
|
29
|
+
* // 目标页面(Vue)— 当页面路由与 pathname 不一致时,手动指定 route
|
|
30
|
+
* onMounted(() => { cleanup = registerPageTool({ route: '/my-page', handlers }) })
|
|
31
|
+
* onUnmounted(() => cleanup())
|
|
32
|
+
*
|
|
33
|
+
* // 目标页面(React)
|
|
34
|
+
* useEffect(() => registerPageTool({ handlers }), [])
|
|
35
|
+
*
|
|
36
|
+
* // 目标页面(Angular)
|
|
37
|
+
* export class MyComponent implements OnInit, OnDestroy {
|
|
38
|
+
* private cleanupPageTool!: () => void
|
|
39
|
+
* ngOnInit() { this.cleanupPageTool = registerPageTool({ handlers }) }
|
|
40
|
+
* ngOnDestroy() { this.cleanupPageTool() }
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* setNavigator 在不同框架中的注册方式:
|
|
44
|
+
* // Vue(main.ts)
|
|
45
|
+
* const router = createRouter(...)
|
|
46
|
+
* app.use(router)
|
|
47
|
+
* setNavigator((route) => router.push(route))
|
|
48
|
+
*
|
|
49
|
+
* // React(App.tsx,使用 react-router-dom)
|
|
50
|
+
* function AppNavigator() {
|
|
51
|
+
* const navigate = useNavigate()
|
|
52
|
+
* useEffect(() => { setNavigator((route) => navigate(route)) }, [navigate])
|
|
53
|
+
* return null
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* // Angular(AppComponent,使用 @angular/router)
|
|
57
|
+
* export class AppComponent {
|
|
58
|
+
* constructor(private router: Router) {
|
|
59
|
+
* setNavigator((route) => this.router.navigateByUrl(route))
|
|
60
|
+
* }
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
import type { ZodRawShape } from 'zod'
|
|
64
|
+
import type { RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
65
|
+
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
|
66
|
+
import type { WebMcpServer } from './WebMcpServer'
|
|
67
|
+
import { randomUUID } from './utils/uuid'
|
|
68
|
+
|
|
69
|
+
// 消息类型常量,使用命名空间前缀避免冲突
|
|
70
|
+
const MSG_TOOL_CALL = 'next-sdk:tool-call'
|
|
71
|
+
const MSG_TOOL_RESPONSE = 'next-sdk:tool-response'
|
|
72
|
+
const MSG_PAGE_READY = 'next-sdk:page-ready'
|
|
73
|
+
/** 页面卸载广播,供 pageToolsOnDemand 模式监听 */
|
|
74
|
+
export const MSG_PAGE_LEAVE = 'next-sdk:page-leave'
|
|
75
|
+
/** iframe 内 Remoter 就绪后向父窗口发送,父窗口回传 route-state-initial */
|
|
76
|
+
export const MSG_REMOTER_READY = 'next-sdk:remoter-ready'
|
|
77
|
+
/** 父窗口向 iframe Remoter 回传的初始路由状态(toolRouteMap + activeRoutes) */
|
|
78
|
+
export const MSG_ROUTE_STATE_INITIAL = 'next-sdk:route-state-initial'
|
|
79
|
+
|
|
80
|
+
// 已激活页面注册表:路由路径 → 是否已挂载
|
|
81
|
+
const activePages = new Map<string, boolean>()
|
|
82
|
+
|
|
83
|
+
type BroadcastTarget = { win: Window; origin: string }
|
|
84
|
+
|
|
85
|
+
// 跨窗口广播目标:同窗口默认 [window],iframe 场景下会加入 remoter 的 contentWindow
|
|
86
|
+
const broadcastTargets = new Set<BroadcastTarget>()
|
|
87
|
+
|
|
88
|
+
function initBroadcastTargets() {
|
|
89
|
+
if (typeof window !== 'undefined') {
|
|
90
|
+
broadcastTargets.add({ win: window, origin: window.location.origin || '*' })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
initBroadcastTargets()
|
|
94
|
+
|
|
95
|
+
/** 向所有广播目标发送路由变更消息(同窗口 + iframe 均能收到) */
|
|
96
|
+
function broadcastRouteChange(type: string, route: string) {
|
|
97
|
+
const msg = { type, route }
|
|
98
|
+
broadcastTargets.forEach(({ win, origin }) => {
|
|
99
|
+
try {
|
|
100
|
+
win.postMessage(msg, origin)
|
|
101
|
+
} catch {
|
|
102
|
+
// 跨域 iframe 可能抛错,忽略
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 监听 iframe 内 Remoter 的 remoter-ready,回传初始路由状态并加入广播目标 */
|
|
108
|
+
function setupIframeRemoterBridge() {
|
|
109
|
+
if (typeof window === 'undefined') return
|
|
110
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
111
|
+
if (event.data?.type !== MSG_REMOTER_READY || !event.source) return
|
|
112
|
+
// 仅接受与当前页面同源的 remoter,避免潜在的 XSS 风险
|
|
113
|
+
if (event.origin !== window.location.origin) return
|
|
114
|
+
const target = event.source as Window
|
|
115
|
+
broadcastTargets.add({ win: target, origin: event.origin || '*' })
|
|
116
|
+
const payload = {
|
|
117
|
+
type: MSG_ROUTE_STATE_INITIAL,
|
|
118
|
+
toolRouteMap: Array.from(toolRouteMap.entries()),
|
|
119
|
+
activeRoutes: Array.from(activePages.keys())
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
target.postMessage(payload, event.origin || '*')
|
|
123
|
+
} catch {
|
|
124
|
+
// 忽略跨域错误
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
setupIframeRemoterBridge()
|
|
129
|
+
|
|
130
|
+
// withPageTools 注册的工具路由映射表:工具名 → 目标路由
|
|
131
|
+
const toolRouteMap = new Map<string, string>()
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 获取通过 withPageTools + RouteConfig 注册的全部工具路由映射。
|
|
135
|
+
* 返回的是内部 Map 的只读快照,可安全遍历。
|
|
136
|
+
* @returns toolName → route 的只读 Map
|
|
137
|
+
*/
|
|
138
|
+
export function getToolRouteMap(): ReadonlyMap<string, string> {
|
|
139
|
+
return new Map(toolRouteMap)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 获取当前已激活(已挂载)的路由集合。
|
|
144
|
+
* 即调用了 registerPageTool 且尚未执行 cleanup 的页面路由。
|
|
145
|
+
* @returns 当前激活路由的 Set 快照
|
|
146
|
+
*/
|
|
147
|
+
export function getActiveRoutes(): Set<string> {
|
|
148
|
+
return new Set(activePages.keys())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 应用注册的导航函数,由 setNavigator 设置
|
|
152
|
+
let _navigator: ((route: string) => void | Promise<void>) | null = null
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 注册应用的导航函数,通常在应用入口(如 main.ts)调用一次。
|
|
156
|
+
* @param fn 导航函数,接收路由路径并执行跳转(如 router.push)
|
|
157
|
+
*/
|
|
158
|
+
export function setNavigator(fn: (route: string) => void | Promise<void>) {
|
|
159
|
+
_navigator = fn
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* registerTool 第三个参数的路由配置对象类型。
|
|
164
|
+
* 当传入此类型时,工具调用会自动跳转到 route 对应的页面并通过消息通信执行。
|
|
165
|
+
*/
|
|
166
|
+
export type RouteConfig = {
|
|
167
|
+
/** 目标路由路径,如 '/comprehensive' */
|
|
168
|
+
route: string
|
|
169
|
+
/** 等待页面响应的超时时间(ms),默认 30000 */
|
|
170
|
+
timeout?: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* PageAwareServer 的 registerTool 配置对象类型,与 WebMcpServer.registerTool 保持一致。
|
|
175
|
+
*/
|
|
176
|
+
type RegisterToolConfig<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape> = {
|
|
177
|
+
title?: string
|
|
178
|
+
description?: string
|
|
179
|
+
inputSchema?: InputArgs
|
|
180
|
+
outputSchema?: OutputArgs
|
|
181
|
+
annotations?: ToolAnnotations
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 包装 WebMcpServer 后的类型:registerTool 第三个参数额外支持 RouteConfig。
|
|
186
|
+
* 泛型签名与 WebMcpServer.registerTool 对齐,保持完整的类型推导能力。
|
|
187
|
+
* 原有的回调函数写法完全兼容,无需改动。
|
|
188
|
+
*/
|
|
189
|
+
export type PageAwareServer = Omit<WebMcpServer, 'registerTool'> & {
|
|
190
|
+
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
|
|
191
|
+
name: string,
|
|
192
|
+
config: RegisterToolConfig<InputArgs, OutputArgs>,
|
|
193
|
+
// handler 不引入 ToolCallback<InputArgs>:该类型含 MCP SDK 深层泛型,
|
|
194
|
+
// 叠加 ZodRawShape 推断链后会触发"类型实例化过深"。
|
|
195
|
+
// 实际类型安全由 Proxy 内部透传给 WebMcpServer.registerTool 保证。
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
|
+
handlerOrRoute: ((...args: any[]) => any) | RouteConfig
|
|
198
|
+
): RegisteredTool
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 内部:根据 name/route/timeout 生成转发给页面的 handler 函数。
|
|
203
|
+
* 调用流程:
|
|
204
|
+
* 1. 若目标路由已激活 → 直接 postMessage 发送工具调用
|
|
205
|
+
* 2. 若未激活 → 调用导航函数跳转,等待 page-ready 信号后再发送
|
|
206
|
+
* 3. 页面处理后回传结果,Promise resolve
|
|
207
|
+
*/
|
|
208
|
+
function buildPageHandler(name: string, route: string, timeout = 30000) {
|
|
209
|
+
return (input: any): Promise<any> => {
|
|
210
|
+
const callId = randomUUID()
|
|
211
|
+
|
|
212
|
+
return new Promise<any>((resolve, reject) => {
|
|
213
|
+
let timer: ReturnType<typeof setTimeout>
|
|
214
|
+
// readyHandler 需在 cleanup 中一并移除,避免导航失败时泄漏监听器
|
|
215
|
+
let readyHandler: ((event: MessageEvent) => void) | undefined
|
|
216
|
+
|
|
217
|
+
const cleanup = () => {
|
|
218
|
+
clearTimeout(timer)
|
|
219
|
+
window.removeEventListener('message', responseHandler)
|
|
220
|
+
if (readyHandler) {
|
|
221
|
+
window.removeEventListener('message', readyHandler)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 超时兜底,防止页面永远不响应
|
|
226
|
+
timer = setTimeout(() => {
|
|
227
|
+
cleanup()
|
|
228
|
+
reject(new Error(`工具 [${name}] 调用超时 (${timeout}ms),请检查目标页面是否正确调用了 registerPageTool`))
|
|
229
|
+
}, timeout)
|
|
230
|
+
|
|
231
|
+
// 通过 callId 精确匹配响应,避免并发调用互相串扰
|
|
232
|
+
const responseHandler = (event: MessageEvent) => {
|
|
233
|
+
if (event.source === window && event.data?.type === MSG_TOOL_RESPONSE && event.data.callId === callId) {
|
|
234
|
+
cleanup()
|
|
235
|
+
event.data.error ? reject(new Error(event.data.error)) : resolve(event.data.result)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
window.addEventListener('message', responseHandler)
|
|
239
|
+
|
|
240
|
+
const sendCall = () => {
|
|
241
|
+
window.postMessage({ type: MSG_TOOL_CALL, callId, toolName: name, route, input }, window.location.origin || '*')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 单次发送守卫:readyHandler 与导航后 activePages 补充检查均可触发 sendCall,
|
|
245
|
+
// 用此 flag 确保同一次工具调用只发送一条消息,防止工具被重复执行。
|
|
246
|
+
let callSent = false
|
|
247
|
+
const sendCallOnce = () => {
|
|
248
|
+
if (callSent) return
|
|
249
|
+
callSent = true
|
|
250
|
+
sendCall()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 将异步导航逻辑提取为独立 run 函数并用 void 调用,
|
|
254
|
+
// 避免在 Promise executor 中直接使用 async(Biome noAsyncPromiseExecutor 规则)。
|
|
255
|
+
// 导航失败时显式 reject,防止外层 Promise 永远挂起。
|
|
256
|
+
const run = async () => {
|
|
257
|
+
try {
|
|
258
|
+
if (activePages.get(route)) {
|
|
259
|
+
// 页面已激活,直接发送
|
|
260
|
+
sendCallOnce()
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ⚠️ 必须先注册 readyHandler 再触发导航:
|
|
265
|
+
// 若先导航再注册,极快的导航(同步或微任务)可能导致
|
|
266
|
+
// 目标页面已广播 page-ready 而监听器尚未挂载,从而错过信号。
|
|
267
|
+
readyHandler = (event: MessageEvent) => {
|
|
268
|
+
if (event.source === window && event.data?.type === MSG_PAGE_READY && event.data.route === route) {
|
|
269
|
+
window.removeEventListener('message', readyHandler!)
|
|
270
|
+
sendCallOnce()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
window.addEventListener('message', readyHandler)
|
|
274
|
+
|
|
275
|
+
if (_navigator) {
|
|
276
|
+
await _navigator(route)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 导航 await 完成后,再次检查 activePages:
|
|
280
|
+
// 若页面在注册监听器与导航之间极短间隙内已激活(极端竞态),
|
|
281
|
+
// message 事件已被 handleMessage 消费但 readyHandler 未执行,
|
|
282
|
+
// 此处补充检查确保不会永久等待。
|
|
283
|
+
// sendCallOnce 保证即使两条路径都触发,消息也只发送一次。
|
|
284
|
+
if (activePages.get(route)) {
|
|
285
|
+
window.removeEventListener('message', readyHandler)
|
|
286
|
+
sendCallOnce()
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
// 导航本身抛出异常时,确保 Promise 被 reject 而非永远挂起
|
|
290
|
+
cleanup()
|
|
291
|
+
reject(err instanceof Error ? err : new Error(String(err)))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
void run()
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 包装 WebMcpServer,使 registerTool 第三个参数支持 RouteConfig。
|
|
301
|
+
*
|
|
302
|
+
* - 第三个参数为**回调函数**:与原始 registerTool 完全一致,直接透传
|
|
303
|
+
* - 第三个参数为 **RouteConfig 对象**:自动生成转发 handler,工具调用时
|
|
304
|
+
* 先导航到目标路由,再通过 postMessage 与页面通信
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* const server = withPageTools(new WebMcpServer())
|
|
308
|
+
*
|
|
309
|
+
* // 路由模式:第三个参数传路由配置(route 必填,timeout 可选,单位 ms,默认 30000)
|
|
310
|
+
* server.registerTool('product-guide', { title, inputSchema }, { route: '/comprehensive', timeout: 15000 })
|
|
311
|
+
*
|
|
312
|
+
* // 普通模式:第三个参数传回调(兼容原有写法)
|
|
313
|
+
* server.registerTool('simple-tool', { title }, async (input) => ({ content: [...] }))
|
|
314
|
+
*/
|
|
315
|
+
export function withPageTools(server: WebMcpServer): PageAwareServer {
|
|
316
|
+
return new Proxy(server, {
|
|
317
|
+
get(target, prop, receiver) {
|
|
318
|
+
if (prop === 'registerTool') {
|
|
319
|
+
return (name: string, config: any, handlerOrRoute: ((...args: any[]) => any) | RouteConfig) => {
|
|
320
|
+
// 第三个参数是函数 → 直接透传,行为与原始 registerTool 完全相同
|
|
321
|
+
// 通过 (target as any) 避免 WebMcpServer.registerTool 深层泛型触发"类型实例化过深"
|
|
322
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
323
|
+
const rawRegister = (target as any).registerTool.bind(target)
|
|
324
|
+
if (typeof handlerOrRoute === 'function') {
|
|
325
|
+
return rawRegister(name, config, handlerOrRoute)
|
|
326
|
+
}
|
|
327
|
+
// 第三个参数是路由配置对象 → 自动生成转发 handler,并记录 tool → route 映射
|
|
328
|
+
const { route, timeout } = handlerOrRoute
|
|
329
|
+
toolRouteMap.set(name, route)
|
|
330
|
+
return rawRegister(name, config, buildPageHandler(name, route, timeout))
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return Reflect.get(target, prop, receiver)
|
|
334
|
+
}
|
|
335
|
+
}) as unknown as PageAwareServer
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 在目标页面激活工具处理器(框架无关的纯 JS 函数)。
|
|
340
|
+
*
|
|
341
|
+
* 调用后立即:
|
|
342
|
+
* - 将路由注册到 activePages(标记页面已激活)
|
|
343
|
+
* - 添加 message 监听,处理来自 buildPageHandler 的工具调用
|
|
344
|
+
* - 广播 page-ready 信号,通知正在等待导航完成的工具
|
|
345
|
+
*
|
|
346
|
+
* 返回 cleanup 函数,页面销毁时调用。
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* // Vue(Composition API)— 省略 route,默认读 window.location.pathname
|
|
350
|
+
* let cleanup: () => void
|
|
351
|
+
* onMounted(() => { cleanup = registerPageTool({ handlers: { ... } }) })
|
|
352
|
+
* onUnmounted(() => cleanup())
|
|
353
|
+
*
|
|
354
|
+
* // Vue — 当页面路由与 pathname 不一致时,手动指定 route
|
|
355
|
+
* onMounted(() => { cleanup = registerPageTool({ route: '/comprehensive', handlers: { ... } }) })
|
|
356
|
+
* onUnmounted(() => cleanup())
|
|
357
|
+
*
|
|
358
|
+
* // React(Hooks)
|
|
359
|
+
* useEffect(() => registerPageTool({ handlers: { ... } }), [])
|
|
360
|
+
* // useEffect 直接返回 cleanup 函数,React 会在组件卸载时自动调用
|
|
361
|
+
*
|
|
362
|
+
* // Angular(实现 OnInit / OnDestroy 接口)
|
|
363
|
+
* export class PriceProtectionComponent implements OnInit, OnDestroy {
|
|
364
|
+
* private cleanupPageTool!: () => void
|
|
365
|
+
*
|
|
366
|
+
* ngOnInit(): void {
|
|
367
|
+
* this.cleanupPageTool = registerPageTool({
|
|
368
|
+
* handlers: {
|
|
369
|
+
* 'price-protection-query': async ({ status }) => { ... },
|
|
370
|
+
* }
|
|
371
|
+
* })
|
|
372
|
+
* }
|
|
373
|
+
*
|
|
374
|
+
* ngOnDestroy(): void {
|
|
375
|
+
* this.cleanupPageTool()
|
|
376
|
+
* }
|
|
377
|
+
* }
|
|
378
|
+
*/
|
|
379
|
+
export function registerPageTool(options: {
|
|
380
|
+
/**
|
|
381
|
+
* 目标路由路径,与 RouteConfig.route 保持一致。
|
|
382
|
+
* 省略时自动读取 window.location.pathname。
|
|
383
|
+
* 当页面路由与 pathname 不一致时(如 hash 路由、子路径前缀等),需手动传入。
|
|
384
|
+
*/
|
|
385
|
+
route?: string
|
|
386
|
+
/**
|
|
387
|
+
* 工具名 → 处理函数的映射表。
|
|
388
|
+
*
|
|
389
|
+
* 此处 handler 的 input 参数类型保留 any:
|
|
390
|
+
* 若改为 unknown,TypeScript 函数参数逆变规则会导致用户的具名解构写法
|
|
391
|
+
*(如 `async ({ productId }: { productId: string }) => ...`)无法通过类型检查,
|
|
392
|
+
* 破坏现有调用方代码的开发体验。运行时输入由 MCP inputSchema 保证类型安全。
|
|
393
|
+
*/
|
|
394
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
395
|
+
handlers: Record<string, (input: any) => Promise<any>>
|
|
396
|
+
}): () => void {
|
|
397
|
+
const { route: routeOption, handlers } = options
|
|
398
|
+
// 规范化路由:去除尾部斜杠,空路径兜底为 '/',确保与 buildPageHandler 侧一致
|
|
399
|
+
// 优先使用用户传入的 route,否则回退到 window.location.pathname
|
|
400
|
+
const normalizeRoute = (value: string) => value.replace(/\/+$/, '') || '/'
|
|
401
|
+
const route = normalizeRoute(routeOption ?? window.location.pathname)
|
|
402
|
+
|
|
403
|
+
const handleMessage = async (event: MessageEvent) => {
|
|
404
|
+
// 同时校验 route 字段,防止多页面注册同名工具时发生跨路由串扰
|
|
405
|
+
// 对消息携带的 route 同样规范化,避免因尾部斜杠等差异导致匹配失败
|
|
406
|
+
if (
|
|
407
|
+
event.source !== window ||
|
|
408
|
+
event.data?.type !== MSG_TOOL_CALL ||
|
|
409
|
+
normalizeRoute(String(event.data?.route ?? '')) !== route ||
|
|
410
|
+
!(event.data.toolName in handlers)
|
|
411
|
+
) {
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
const { callId, toolName, input } = event.data
|
|
415
|
+
try {
|
|
416
|
+
const result = await handlers[toolName](input)
|
|
417
|
+
window.postMessage({ type: MSG_TOOL_RESPONSE, callId, result }, window.location.origin || '*')
|
|
418
|
+
} catch (err) {
|
|
419
|
+
window.postMessage(
|
|
420
|
+
{
|
|
421
|
+
type: MSG_TOOL_RESPONSE,
|
|
422
|
+
callId,
|
|
423
|
+
error: err instanceof Error ? err.message : String(err)
|
|
424
|
+
},
|
|
425
|
+
window.location.origin || '*'
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 注册页面为已激活状态并广播就绪信号(同窗口 + iframe Remoter 均能收到)
|
|
431
|
+
activePages.set(route, true)
|
|
432
|
+
window.addEventListener('message', handleMessage)
|
|
433
|
+
broadcastRouteChange(MSG_PAGE_READY, route)
|
|
434
|
+
|
|
435
|
+
// 返回 cleanup,由各框架在页面销毁时调用
|
|
436
|
+
return () => {
|
|
437
|
+
activePages.delete(route)
|
|
438
|
+
window.removeEventListener('message', handleMessage)
|
|
439
|
+
broadcastRouteChange(MSG_PAGE_LEAVE, route)
|
|
440
|
+
}
|
|
441
|
+
}
|
package/remoter/createRemoter.ts
CHANGED
|
@@ -39,8 +39,8 @@ export interface FloatingBlockOptions {
|
|
|
39
39
|
|
|
40
40
|
/** 遥控端页面地址,默认为: https://ai.opentiny.design/next-remoter */
|
|
41
41
|
qrCodeUrl?: string
|
|
42
|
-
/** 被遥控页面的 sessionId
|
|
43
|
-
sessionId
|
|
42
|
+
/** 被遥控页面的 sessionId;无 sessionId 时仅显示「打开对话框」菜单,不显示二维码、识别码、遥控器链接 */
|
|
43
|
+
sessionId?: string
|
|
44
44
|
/** 菜单项配置 */
|
|
45
45
|
menuItems?: MenuItemConfig[]
|
|
46
46
|
/** 遥控端页面地址,默认为: https://chat.opentiny.design */
|
|
@@ -52,11 +52,13 @@ export interface FloatingBlockOptions {
|
|
|
52
52
|
// 动作类型
|
|
53
53
|
export type ActionType = 'qr-code' | 'ai-chat' | 'remote-control' | 'remote-url'
|
|
54
54
|
|
|
55
|
+
/** 有 sessionId 时的完整菜单;无 sessionId 时仅返回 ai-chat(不依赖会话的菜单项) */
|
|
55
56
|
const getDefaultMenuItems = (options: FloatingBlockOptions): MenuItemConfig[] => {
|
|
56
|
-
|
|
57
|
+
const hasSession = !!options.sessionId
|
|
58
|
+
const baseItems: MenuItemConfig[] = [
|
|
57
59
|
{
|
|
58
60
|
action: 'qr-code',
|
|
59
|
-
show:
|
|
61
|
+
show: hasSession,
|
|
60
62
|
text: '扫码登录',
|
|
61
63
|
desc: '使用手机遥控页面',
|
|
62
64
|
icon: qrCode
|
|
@@ -70,7 +72,7 @@ const getDefaultMenuItems = (options: FloatingBlockOptions): MenuItemConfig[] =>
|
|
|
70
72
|
},
|
|
71
73
|
{
|
|
72
74
|
action: 'remote-url',
|
|
73
|
-
show:
|
|
75
|
+
show: hasSession,
|
|
74
76
|
text: `遥控器链接`,
|
|
75
77
|
desc: `${options.remoteUrl}`,
|
|
76
78
|
active: true,
|
|
@@ -80,14 +82,15 @@ const getDefaultMenuItems = (options: FloatingBlockOptions): MenuItemConfig[] =>
|
|
|
80
82
|
},
|
|
81
83
|
{
|
|
82
84
|
action: 'remote-control',
|
|
83
|
-
show:
|
|
85
|
+
show: hasSession,
|
|
84
86
|
text: `识别码`,
|
|
85
|
-
desc: `${options.sessionId
|
|
87
|
+
desc: hasSession ? `${options.sessionId!.slice(-6)}` : '',
|
|
86
88
|
know: true,
|
|
87
89
|
showCopyIcon: true,
|
|
88
90
|
icon: scan
|
|
89
91
|
}
|
|
90
92
|
]
|
|
93
|
+
return baseItems
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
class FloatingBlock {
|
|
@@ -105,10 +108,6 @@ class FloatingBlock {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
constructor(options: FloatingBlockOptions) {
|
|
108
|
-
if (!options.sessionId) {
|
|
109
|
-
throw new Error('sessionId is required')
|
|
110
|
-
}
|
|
111
|
-
|
|
112
111
|
this.options = {
|
|
113
112
|
...options,
|
|
114
113
|
qrCodeUrl: options.qrCodeUrl || DEFAULT_QR_CODE_URL,
|
|
@@ -146,27 +145,32 @@ class FloatingBlock {
|
|
|
146
145
|
}
|
|
147
146
|
|
|
148
147
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
148
|
+
* 合并菜单项配置。
|
|
149
|
+
* - 有 sessionId:使用默认菜单 + 用户配置(可定制每一项的 show/text/icon 等)
|
|
150
|
+
* - 无 sessionId:不渲染任何下拉菜单,仅保留点击浮标打开对话框的能力
|
|
152
151
|
*/
|
|
153
152
|
private mergeMenuItems(userMenuItems?: MenuItemConfig[]): MenuItemConfig[] {
|
|
153
|
+
// 无 sessionId:完全关闭下拉菜单(包括 ai-chat 项),只保留点击浮标触发 onShowAIChat
|
|
154
|
+
if (!this.options.sessionId) {
|
|
155
|
+
return []
|
|
156
|
+
}
|
|
157
|
+
|
|
154
158
|
if (!userMenuItems) {
|
|
155
159
|
return getDefaultMenuItems(this.options)
|
|
156
160
|
}
|
|
157
161
|
|
|
158
|
-
return getDefaultMenuItems(this.options)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
return getDefaultMenuItems(this.options)
|
|
163
|
+
.map((defaultItem) => {
|
|
164
|
+
const userItem = userMenuItems.find((item) => item.action === defaultItem.action)
|
|
165
|
+
if (userItem) {
|
|
166
|
+
return {
|
|
167
|
+
...defaultItem,
|
|
168
|
+
...userItem,
|
|
169
|
+
show: userItem.show !== undefined ? userItem.show : defaultItem.show
|
|
170
|
+
}
|
|
166
171
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
})
|
|
172
|
+
return defaultItem
|
|
173
|
+
})
|
|
170
174
|
}
|
|
171
175
|
|
|
172
176
|
private init(): void {
|
|
@@ -354,10 +358,12 @@ class FloatingBlock {
|
|
|
354
358
|
}
|
|
355
359
|
|
|
356
360
|
private copyRemoteControl(): void {
|
|
361
|
+
if (!this.options.sessionId) return
|
|
357
362
|
this.copyToClipboard(this.options.sessionId.slice(-6))
|
|
358
363
|
}
|
|
359
364
|
|
|
360
365
|
private copyRemoteURL(): void {
|
|
366
|
+
if (!this.options.sessionId) return
|
|
361
367
|
this.copyToClipboard(this.options.remoteUrl + this.sessionPrefix + this.options.sessionId)
|
|
362
368
|
}
|
|
363
369
|
|
|
@@ -417,8 +423,9 @@ class FloatingBlock {
|
|
|
417
423
|
}, 1500)
|
|
418
424
|
}
|
|
419
425
|
|
|
420
|
-
//
|
|
426
|
+
// 创建二维码弹窗(无 sessionId 时不展示)
|
|
421
427
|
private async showQRCode(): Promise<void> {
|
|
428
|
+
if (!this.options.sessionId) return
|
|
422
429
|
const qrCode = new QrCode((this.options.qrCodeUrl || '') + this.sessionPrefix + this.options.sessionId, {})
|
|
423
430
|
const base64 = await qrCode.toDataURL()
|
|
424
431
|
const modal = this.createModal(
|