@opentiny/next-sdk 0.2.7 → 0.2.9

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * page-tool-bridge - Web MCP 页面工具桥接模块(框架无关)
2
+ * page-tools/bridge - Web MCP 页面工具桥接模块(框架无关)
3
3
  *
4
4
  * 解决 Web-MCP 工具动态加载问题:工具定义(mcp-servers/)不直接写业务逻辑,
5
5
  * 而是通过 window.postMessage 将调用转发给目标页面,页面处理后返回结果。
@@ -10,61 +10,16 @@
10
10
  * 包装 WebMcpServer,让 registerTool 第三个参数
11
11
  * 同时支持原始回调函数和路由配置对象(RouteConfig)
12
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
13
  */
14
+
63
15
  import type { ZodRawShape } from 'zod'
16
+ import { z } from 'zod'
64
17
  import type { RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'
65
18
  import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
66
- import type { WebMcpServer } from './WebMcpServer'
67
- import { randomUUID } from './utils/uuid'
19
+ import type { WebMcpServer } from '../WebMcpServer'
20
+ import { randomUUID } from '../utils/uuid'
21
+ import type { ToolInvokeEffectConfig } from './effects'
22
+ import { hideToolInvokeEffect, resolveRuntimeEffectConfig, showToolInvokeEffect } from './effects'
68
23
 
69
24
  // 消息类型常量,使用命名空间前缀避免冲突
70
25
  const MSG_TOOL_CALL = 'next-sdk:tool-call'
@@ -80,6 +35,9 @@ export const MSG_ROUTE_STATE_INITIAL = 'next-sdk:route-state-initial'
80
35
  // 已激活页面注册表:路由路径 → 是否已挂载
81
36
  const activePages = new Map<string, boolean>()
82
37
 
38
+ // 路由路径规范化:去除尾部斜杠,空路径兜底为 '/'
39
+ const normalizeRoute = (value: string) => value.replace(/\/+$/, '') || '/'
40
+
83
41
  type BroadcastTarget = { win: Window; origin: string }
84
42
 
85
43
  // 跨窗口广播目标:同窗口默认 [window],iframe 场景下会加入 remoter 的 contentWindow
@@ -159,6 +117,42 @@ export function setNavigator(fn: (route: string) => void | Promise<void>) {
159
117
  _navigator = fn
160
118
  }
161
119
 
120
+ /**
121
+ * 等待指定路由页面完成挂载并广播 page-ready。
122
+ * - 仅在浏览器环境下生效(window 存在时)
123
+ * - 使用与 registerPageTool 相同的路由规范化规则
124
+ * - 内置兜底超时,防止 Promise 永远不 resolve
125
+ */
126
+ function waitForPageReady(path: string, timeoutMs = 1500): Promise<void> {
127
+ if (typeof window === 'undefined') {
128
+ return Promise.resolve()
129
+ }
130
+
131
+ const target = normalizeRoute(path)
132
+
133
+ return new Promise<void>((resolve) => {
134
+ let done = false
135
+
136
+ const cleanup = () => {
137
+ if (done) return
138
+ done = true
139
+ window.removeEventListener('message', handleMessage)
140
+ resolve()
141
+ }
142
+
143
+ const handleMessage = (event: MessageEvent) => {
144
+ if (event.source !== window || event.data?.type !== MSG_PAGE_READY) return
145
+ const route = normalizeRoute(String(event.data.route ?? ''))
146
+ if (route === target) {
147
+ cleanup()
148
+ }
149
+ }
150
+
151
+ window.addEventListener('message', handleMessage)
152
+ setTimeout(cleanup, timeoutMs)
153
+ })
154
+ }
155
+
162
156
  /**
163
157
  * registerTool 第三个参数的路由配置对象类型。
164
158
  * 当传入此类型时,工具调用会自动跳转到 route 对应的页面并通过消息通信执行。
@@ -168,8 +162,19 @@ export type RouteConfig = {
168
162
  route: string
169
163
  /** 等待页面响应的超时时间(ms),默认 30000 */
170
164
  timeout?: number
165
+ /**
166
+ * 是否在调用该工具时启用页面级调用提示效果。
167
+ *
168
+ * - false / 未配置:不启用任何额外效果(保持现有行为)
169
+ * - true:使用默认提示文案(优先取工具标题,其次为工具名)
170
+ * - 对象:可自定义提示文案
171
+ */
172
+ invokeEffect?: boolean | ToolInvokeEffectConfig
171
173
  }
172
174
 
175
+ // 对外暴露调用提示配置类型,便于业务方在 RouteConfig 外单独复用
176
+ export type { ToolInvokeEffectConfig }
177
+
173
178
  /**
174
179
  * PageAwareServer 的 registerTool 配置对象类型,与 WebMcpServer.registerTool 保持一致。
175
180
  */
@@ -198,6 +203,100 @@ export type PageAwareServer = Omit<WebMcpServer, 'registerTool'> & {
198
203
  ): RegisteredTool
199
204
  }
200
205
 
206
+ /**
207
+ * 注册一个通用的页面跳转工具(navigate_to_page),供大模型在需要时主动跳转到指定路由。
208
+ *
209
+ * 要求:
210
+ * - 业务侧在应用入口通过 setNavigator 注册导航函数(如 router.push 或 navigateByUrl)
211
+ * - 前端页面在目标路由下调用 registerPageTool,确保 page-ready 能正确广播
212
+ *
213
+ * 工具行为:
214
+ * - 输入 path(如 "/orders"、"/price-protection"),调用 setNavigator 注册的函数执行跳转
215
+ * - 等待目标页面完成挂载并广播 page-ready(或在超时时间到达时兜底返回)
216
+ * - 返回简单的文本说明,提示跳转结果
217
+ */
218
+ export type NavigateToolOptions = {
219
+ /** 工具名称,默认 'navigate_to_page' */
220
+ name?: string
221
+ /** 工具标题,默认 '页面跳转' */
222
+ title?: string
223
+ /** 工具描述 */
224
+ description?: string
225
+ /** 等待 page-ready 的超时时间(ms),默认 1500 */
226
+ timeoutMs?: number
227
+ }
228
+
229
+ export function registerNavigateTool(server: WebMcpServer, options?: NavigateToolOptions): RegisteredTool {
230
+ const name = options?.name ?? 'navigate_to_page'
231
+ const title = options?.title ?? '页面跳转'
232
+ const description =
233
+ options?.description ??
234
+ '当需要的工具在当前页面不可用时,使用此工具跳转到特定页面。例如:要查询订单时跳转到 "/orders",要创建价保时跳转到 "/price-protection"。'
235
+ const timeoutMs = options?.timeoutMs ?? 1500
236
+
237
+ return server.registerTool(
238
+ name,
239
+ {
240
+ title,
241
+ description,
242
+ inputSchema: {
243
+ path: z.string().describe('目标页面的路由地址,例如 "/orders"、"/inventory"、"/price-protection" 等。')
244
+ }
245
+ },
246
+ async ({ path }: { path: string }) => {
247
+ if (typeof window === 'undefined') {
248
+ return {
249
+ content: [{ type: 'text', text: '当前环境不支持页面跳转(window 不存在)。' }]
250
+ }
251
+ }
252
+
253
+ if (!_navigator) {
254
+ return {
255
+ content: [
256
+ {
257
+ type: 'text',
258
+ text: '页面跳转失败:尚未在应用入口调用 setNavigator 注册导航函数,无法执行路由跳转。'
259
+ }
260
+ ]
261
+ }
262
+ }
263
+
264
+ try {
265
+ const target = normalizeRoute(path)
266
+ // 若当前已在目标路由上,直接返回成功,避免不必要的跳转。
267
+ // 兼容子路径部署(如 base: '/ai-vue/'):pathname 可能为 /ai-vue/orders,而 path 为 /orders,需判断 pathname 是否“以目标路由结尾”
268
+ const current = normalizeRoute(window.location.pathname)
269
+ const isAlreadyOnTarget =
270
+ current === target ||
271
+ (current.endsWith(target) && (current.length === target.length || current[current.lastIndexOf(target) - 1] === '/'))
272
+ if (isAlreadyOnTarget) {
273
+ return {
274
+ content: [{ type: 'text', text: `当前已在页面:${path}。请继续你的下一步操作。` }]
275
+ }
276
+ }
277
+
278
+ // 先注册 page-ready 监听再触发导航,避免极快导航下事件先于监听器触发而漏收(与 buildPageHandler 中的顺序一致)
279
+ const readyPromise = waitForPageReady(path, timeoutMs)
280
+ await _navigator(path)
281
+ await readyPromise
282
+
283
+ return {
284
+ content: [{ type: 'text', text: `已成功跳转至页面:${path}。请继续你的下一步操作。` }]
285
+ }
286
+ } catch (err) {
287
+ return {
288
+ content: [
289
+ {
290
+ type: 'text',
291
+ text: `页面跳转失败:${err instanceof Error ? err.message : String(err)}。`
292
+ }
293
+ ]
294
+ }
295
+ }
296
+ }
297
+ )
298
+ }
299
+
201
300
  /**
202
301
  * 内部:根据 name/route/timeout 生成转发给页面的 handler 函数。
203
302
  * 调用流程:
@@ -205,7 +304,12 @@ export type PageAwareServer = Omit<WebMcpServer, 'registerTool'> & {
205
304
  * 2. 若未激活 → 调用导航函数跳转,等待 page-ready 信号后再发送
206
305
  * 3. 页面处理后回传结果,Promise resolve
207
306
  */
208
- function buildPageHandler(name: string, route: string, timeout = 30000) {
307
+ function buildPageHandler(
308
+ name: string,
309
+ route: string,
310
+ timeout = 30000,
311
+ effectConfig?: ReturnType<typeof resolveRuntimeEffectConfig>
312
+ ) {
209
313
  return (input: any): Promise<any> => {
210
314
  const callId = randomUUID()
211
315
 
@@ -220,6 +324,10 @@ function buildPageHandler(name: string, route: string, timeout = 30000) {
220
324
  if (readyHandler) {
221
325
  window.removeEventListener('message', readyHandler)
222
326
  }
327
+ // 工具调用完成(成功 / 失败 / 超时 / 导航异常)后,无论结果如何都需要关闭调用提示效果
328
+ if (effectConfig) {
329
+ hideToolInvokeEffect()
330
+ }
223
331
  }
224
332
 
225
333
  // 超时兜底,防止页面永远不响应
@@ -255,6 +363,11 @@ function buildPageHandler(name: string, route: string, timeout = 30000) {
255
363
  // 导航失败时显式 reject,防止外层 Promise 永远挂起。
256
364
  const run = async () => {
257
365
  try {
366
+ // 一旦真正发起工具调用(无论页面是否已激活),优先开启页面调用提示效果
367
+ if (effectConfig) {
368
+ showToolInvokeEffect(effectConfig)
369
+ }
370
+
258
371
  if (activePages.get(route)) {
259
372
  // 页面已激活,直接发送
260
373
  sendCallOnce()
@@ -302,15 +415,6 @@ function buildPageHandler(name: string, route: string, timeout = 30000) {
302
415
  * - 第三个参数为**回调函数**:与原始 registerTool 完全一致,直接透传
303
416
  * - 第三个参数为 **RouteConfig 对象**:自动生成转发 handler,工具调用时
304
417
  * 先导航到目标路由,再通过 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
418
  */
315
419
  export function withPageTools(server: WebMcpServer): PageAwareServer {
316
420
  return new Proxy(server, {
@@ -325,9 +429,10 @@ export function withPageTools(server: WebMcpServer): PageAwareServer {
325
429
  return rawRegister(name, config, handlerOrRoute)
326
430
  }
327
431
  // 第三个参数是路由配置对象 → 自动生成转发 handler,并记录 tool → route 映射
328
- const { route, timeout } = handlerOrRoute
432
+ const { route, timeout, invokeEffect } = handlerOrRoute
329
433
  toolRouteMap.set(name, route)
330
- return rawRegister(name, config, buildPageHandler(name, route, timeout))
434
+ const effectConfig = resolveRuntimeEffectConfig(name, config?.title, invokeEffect)
435
+ return rawRegister(name, config, buildPageHandler(name, route, timeout, effectConfig))
331
436
  }
332
437
  }
333
438
  return Reflect.get(target, prop, receiver)
@@ -344,37 +449,6 @@ export function withPageTools(server: WebMcpServer): PageAwareServer {
344
449
  * - 广播 page-ready 信号,通知正在等待导航完成的工具
345
450
  *
346
451
  * 返回 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
452
  */
379
453
  export function registerPageTool(options: {
380
454
  /**
@@ -439,3 +513,4 @@ export function registerPageTool(options: {
439
513
  broadcastRouteChange(MSG_PAGE_LEAVE, route)
440
514
  }
441
515
  }
516
+
@@ -0,0 +1,343 @@
1
+ /**
2
+ * page-tools/effects - 页面工具调用提示效果模块(框架无关)
3
+ *
4
+ * 作用:
5
+ * - 在调用通过 withPageTools 注册的页面工具时,显示页面级的调用状态提示
6
+ * - 以左下角小 tip 形式展示“当前正在调用的工具”文案,尽量不打扰用户操作
7
+ * - 纯 DOM + CSS 实现,不依赖 Vue/React 等框架
8
+ */
9
+
10
+ export type ToolInvokeEffectConfig = {
11
+ /**
12
+ * 自定义提示文案,默认使用“工具标题 || 工具名称”
13
+ * 例如:"正在为你整理订单数据"
14
+ */
15
+ label?: string
16
+ }
17
+
18
+ type RuntimeEffectConfig = {
19
+ label: string
20
+ }
21
+
22
+ let overlayElement: HTMLDivElement | null = null
23
+ let labelElement: HTMLDivElement | null = null
24
+ let styleElement: HTMLStyleElement | null = null
25
+ let activeCount = 0
26
+
27
+ const BODY_GLOW_CLASS = 'next-sdk-tool-body-glow'
28
+
29
+ function ensureDomReady() {
30
+ return typeof window !== 'undefined' && typeof document !== 'undefined'
31
+ }
32
+
33
+ function ensureStyleElement() {
34
+ if (!ensureDomReady()) return
35
+ if (styleElement) return
36
+
37
+ const style = document.createElement('style')
38
+ style.textContent = `
39
+ .${BODY_GLOW_CLASS} {
40
+ position: relative;
41
+ }
42
+
43
+ .next-sdk-tool-overlay {
44
+ position: fixed;
45
+ inset: 0;
46
+ z-index: 999999;
47
+ pointer-events: none;
48
+ display: flex;
49
+ align-items: flex-end;
50
+ justify-content: flex-start;
51
+ padding: 0 0 18px 18px;
52
+ background: transparent;
53
+ animation: next-sdk-overlay-fade-in 260ms ease-out;
54
+ }
55
+
56
+ .next-sdk-tool-overlay--exit {
57
+ animation: next-sdk-overlay-fade-out 220ms ease-in forwards;
58
+ }
59
+
60
+ .next-sdk-tool-overlay__glow-ring {
61
+ display: none;
62
+ }
63
+
64
+ .next-sdk-tool-overlay__panel {
65
+ position: relative;
66
+ min-width: min(320px, 78vw);
67
+ max-width: min(420px, 82vw);
68
+ padding: 10px 14px;
69
+ border-radius: 999px;
70
+ background:
71
+ linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(17, 24, 39, 0.9)),
72
+ radial-gradient(circle at top left, rgba(96, 165, 250, 0.25), transparent 55%),
73
+ radial-gradient(circle at bottom right, rgba(45, 212, 191, 0.22), transparent 60%);
74
+ box-shadow:
75
+ 0 12px 28px rgba(15, 23, 42, 0.78),
76
+ 0 0 0 1px rgba(148, 163, 184, 0.26);
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 10px;
80
+ pointer-events: none;
81
+ transform-origin: center;
82
+ animation: next-sdk-panel-pop-in 260ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
83
+ color: #e5e7eb;
84
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
85
+ }
86
+
87
+ .next-sdk-tool-overlay__indicator {
88
+ width: 26px;
89
+ height: 26px;
90
+ border-radius: 999px;
91
+ background: radial-gradient(circle at 30% 10%, #f9fafb, #93c5fd);
92
+ box-shadow:
93
+ 0 0 0 1px rgba(191, 219, 254, 0.6),
94
+ 0 8px 18px rgba(37, 99, 235, 0.8),
95
+ 0 0 28px rgba(56, 189, 248, 0.9);
96
+ position: relative;
97
+ flex-shrink: 0;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ overflow: hidden;
102
+ }
103
+
104
+ .next-sdk-tool-overlay__indicator-orbit {
105
+ position: absolute;
106
+ inset: 2px;
107
+ border-radius: inherit;
108
+ border: 1px solid rgba(248, 250, 252, 0.6);
109
+ box-sizing: border-box;
110
+ opacity: 0.9;
111
+ }
112
+
113
+ .next-sdk-tool-overlay__indicator-orbit::before {
114
+ content: '';
115
+ position: absolute;
116
+ width: 6px;
117
+ height: 6px;
118
+ border-radius: 999px;
119
+ background: #f9fafb;
120
+ box-shadow:
121
+ 0 0 12px rgba(248, 250, 252, 0.9),
122
+ 0 0 24px rgba(250, 249, 246, 0.9);
123
+ top: 0;
124
+ left: 50%;
125
+ transform: translate(-50%, -50%);
126
+ transform-origin: 50% 18px;
127
+ animation: next-sdk-indicator-orbit 1.4s linear infinite;
128
+ }
129
+
130
+ .next-sdk-tool-overlay__indicator-core {
131
+ width: 14px;
132
+ height: 14px;
133
+ border-radius: inherit;
134
+ background: radial-gradient(circle at 30% 20%, #f9fafb, #bfdbfe);
135
+ box-shadow:
136
+ 0 0 12px rgba(248, 250, 252, 0.9),
137
+ 0 0 32px rgba(191, 219, 254, 0.8);
138
+ opacity: 0.96;
139
+ }
140
+
141
+ .next-sdk-tool-overlay__content {
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 1px;
145
+ min-width: 0;
146
+ }
147
+
148
+ .next-sdk-tool-overlay__title {
149
+ font-size: 11px;
150
+ letter-spacing: 0.08em;
151
+ text-transform: uppercase;
152
+ color: rgba(156, 163, 175, 0.96);
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 6px;
156
+ }
157
+
158
+ .next-sdk-tool-overlay__title-dot {
159
+ width: 6px;
160
+ height: 6px;
161
+ border-radius: 999px;
162
+ background: #22c55e;
163
+ box-shadow:
164
+ 0 0 8px rgba(34, 197, 94, 0.9),
165
+ 0 0 14px rgba(22, 163, 74, 0.9);
166
+ }
167
+
168
+ .next-sdk-tool-overlay__label {
169
+ font-size: 12px;
170
+ font-weight: 500;
171
+ color: #e5e7eb;
172
+ white-space: nowrap;
173
+ text-overflow: ellipsis;
174
+ overflow: hidden;
175
+ }
176
+
177
+ @keyframes next-sdk-overlay-fade-in {
178
+ from { opacity: 0; }
179
+ to { opacity: 1; }
180
+ }
181
+
182
+ @keyframes next-sdk-overlay-fade-out {
183
+ from { opacity: 1; }
184
+ to { opacity: 0; }
185
+ }
186
+
187
+ @keyframes next-sdk-indicator-orbit {
188
+ from {
189
+ transform: translate(-50%, -50%) rotate(0deg) translateY(0);
190
+ }
191
+ to {
192
+ transform: translate(-50%, -50%) rotate(360deg) translateY(0);
193
+ }
194
+ }
195
+
196
+ @keyframes next-sdk-panel-pop-in {
197
+ 0% {
198
+ opacity: 0;
199
+ transform: scale(0.92) translateY(10px);
200
+ }
201
+ 100% {
202
+ opacity: 1;
203
+ transform: scale(1) translateY(0);
204
+ }
205
+ }
206
+ `
207
+
208
+ document.head.appendChild(style)
209
+ styleElement = style
210
+ }
211
+
212
+ function ensureOverlayElement() {
213
+ if (!ensureDomReady()) return
214
+ if (overlayElement) return
215
+
216
+ ensureStyleElement()
217
+
218
+ const overlay = document.createElement('div')
219
+ overlay.className = 'next-sdk-tool-overlay'
220
+
221
+ const glowRing = document.createElement('div')
222
+ glowRing.className = 'next-sdk-tool-overlay__glow-ring'
223
+
224
+ const panel = document.createElement('div')
225
+ panel.className = 'next-sdk-tool-overlay__panel'
226
+
227
+ const indicator = document.createElement('div')
228
+ indicator.className = 'next-sdk-tool-overlay__indicator'
229
+
230
+ const indicatorOrbit = document.createElement('div')
231
+ indicatorOrbit.className = 'next-sdk-tool-overlay__indicator-orbit'
232
+
233
+ const indicatorCore = document.createElement('div')
234
+ indicatorCore.className = 'next-sdk-tool-overlay__indicator-core'
235
+
236
+ const content = document.createElement('div')
237
+ content.className = 'next-sdk-tool-overlay__content'
238
+
239
+ const titleRow = document.createElement('div')
240
+ titleRow.className = 'next-sdk-tool-overlay__title'
241
+ titleRow.textContent = 'AI 正在调用页面工具'
242
+
243
+ const titleDot = document.createElement('span')
244
+ titleDot.className = 'next-sdk-tool-overlay__title-dot'
245
+
246
+ const label = document.createElement('div')
247
+ label.className = 'next-sdk-tool-overlay__label'
248
+
249
+ titleRow.prepend(titleDot)
250
+ content.appendChild(titleRow)
251
+ content.appendChild(label)
252
+
253
+ indicator.appendChild(indicatorOrbit)
254
+ indicator.appendChild(indicatorCore)
255
+ panel.appendChild(indicator)
256
+ panel.appendChild(content)
257
+ overlay.appendChild(glowRing)
258
+ overlay.appendChild(panel)
259
+
260
+ document.body.appendChild(overlay)
261
+
262
+ overlayElement = overlay
263
+ labelElement = label
264
+ }
265
+
266
+ function updateOverlay(config: RuntimeEffectConfig) {
267
+ if (!ensureDomReady()) return
268
+ ensureOverlayElement()
269
+ if (!overlayElement || !labelElement) return
270
+ overlayElement.classList.remove('next-sdk-tool-overlay--exit')
271
+ labelElement.textContent = config.label
272
+ }
273
+
274
+ function removeOverlayWithAnimation() {
275
+ if (!overlayElement) return
276
+ overlayElement.classList.add('next-sdk-tool-overlay--exit')
277
+ const localOverlay = overlayElement
278
+ let handled = false
279
+ let timerId: ReturnType<typeof setTimeout> | undefined
280
+ const handle = () => {
281
+ if (handled) return
282
+ handled = true
283
+ if (timerId !== undefined) {
284
+ clearTimeout(timerId)
285
+ timerId = undefined
286
+ }
287
+ if (localOverlay.parentNode) {
288
+ localOverlay.parentNode.removeChild(localOverlay)
289
+ }
290
+ if (overlayElement === localOverlay) {
291
+ overlayElement = null
292
+ labelElement = null
293
+ }
294
+ localOverlay.removeEventListener('animationend', handle)
295
+ }
296
+ localOverlay.addEventListener('animationend', handle)
297
+ // 兜底:若 animationend 未触发(如 prefers-reduced-motion 或样式被覆盖),确保清理
298
+ timerId = setTimeout(handle, 500)
299
+ }
300
+
301
+ /**
302
+ * 在页面上显示工具调用提示效果。
303
+ * - 若当前已有其他工具在执行,仅更新文案,不重复创建 DOM
304
+ * - 会增加 activeCount 计数,需配对调用 hideToolInvokeEffect
305
+ */
306
+ export function showToolInvokeEffect(config: RuntimeEffectConfig) {
307
+ if (!ensureDomReady()) return
308
+ activeCount += 1
309
+ updateOverlay(config)
310
+ }
311
+
312
+ /**
313
+ * 隐藏工具调用提示效果。
314
+ * - 使用引用计数:只有当所有调用结束后才真正移除 Overlay
315
+ */
316
+ export function hideToolInvokeEffect() {
317
+ if (!ensureDomReady() || activeCount <= 0) return
318
+ activeCount -= 1
319
+ if (activeCount === 0) {
320
+ removeOverlayWithAnimation()
321
+ }
322
+ }
323
+
324
+ /**
325
+ * 将 RouteConfig 中的 invokeEffect 配置编译成运行时效果配置。
326
+ * - boolean / undefined:关闭或开启默认文案
327
+ * - 对象:允许自定义 label
328
+ */
329
+ export function resolveRuntimeEffectConfig(
330
+ toolName: string,
331
+ toolTitle: string | undefined,
332
+ value: boolean | ToolInvokeEffectConfig | undefined
333
+ ): RuntimeEffectConfig | undefined {
334
+ if (!value) return undefined
335
+ const baseLabel = toolTitle || toolName
336
+ if (typeof value === 'boolean') {
337
+ return value ? { label: baseLabel } : undefined
338
+ }
339
+ return {
340
+ label: value.label || baseLabel
341
+ }
342
+ }
343
+