@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.
- package/agent/AgentModelProvider.ts +22 -10
- package/dist/agent/AgentModelProvider.d.ts +3 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.es.dev.js +476 -23
- package/dist/index.es.js +6622 -6273
- package/dist/index.js +2120 -1771
- package/dist/index.umd.dev.js +476 -23
- package/dist/index.umd.js +217 -49
- package/dist/{page-tool-bridge.d.ts → page-tools/bridge.d.ts} +34 -42
- package/dist/page-tools/effects.d.ts +36 -0
- package/dist/remoter/createRemoter.d.ts +2 -0
- package/dist/skills/index.d.ts +2 -1
- package/dist/webagent.dev.js +31 -7
- package/dist/webagent.es.dev.js +31 -7
- package/dist/webagent.es.js +33 -17
- package/dist/webagent.js +3 -3
- package/index.ts +1 -1
- package/package.json +1 -1
- package/{page-tool-bridge.ts → page-tools/bridge.ts} +170 -95
- package/page-tools/effects.ts +343 -0
- package/remoter/createRemoter.ts +25 -11
- package/skills/index.ts +109 -12
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* page-
|
|
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 '
|
|
67
|
-
import { randomUUID } from '
|
|
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(
|
|
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
|
-
|
|
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
|
+
|