@opentiny/next-sdk 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+
@@ -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: string
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
- return [
57
+ const hasSession = !!options.sessionId
58
+ const baseItems: MenuItemConfig[] = [
57
59
  {
58
60
  action: 'qr-code',
59
- show: true,
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: true,
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: true,
85
+ show: hasSession,
84
86
  text: `识别码`,
85
- desc: `${options.sessionId.slice(-6)}`,
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,11 +145,16 @@ class FloatingBlock {
146
145
  }
147
146
 
148
147
  /**
149
- * 合并菜单项配置
150
- * @param userMenuItems 用户自定义菜单项配置
151
- * @returns 合并后的菜单项配置
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
  }
@@ -161,7 +165,6 @@ class FloatingBlock {
161
165
  return {
162
166
  ...defaultItem,
163
167
  ...userItem,
164
- // 确保show属性存在,默认为true
165
168
  show: userItem.show !== undefined ? userItem.show : defaultItem.show
166
169
  }
167
170
  }
@@ -354,10 +357,12 @@ class FloatingBlock {
354
357
  }
355
358
 
356
359
  private copyRemoteControl(): void {
360
+ if (!this.options.sessionId) return
357
361
  this.copyToClipboard(this.options.sessionId.slice(-6))
358
362
  }
359
363
 
360
364
  private copyRemoteURL(): void {
365
+ if (!this.options.sessionId) return
361
366
  this.copyToClipboard(this.options.remoteUrl + this.sessionPrefix + this.options.sessionId)
362
367
  }
363
368
 
@@ -417,8 +422,9 @@ class FloatingBlock {
417
422
  }, 1500)
418
423
  }
419
424
 
420
- // 创建二维码弹窗
425
+ // 创建二维码弹窗(无 sessionId 时不展示)
421
426
  private async showQRCode(): Promise<void> {
427
+ if (!this.options.sessionId) return
422
428
  const qrCode = new QrCode((this.options.qrCodeUrl || '') + this.sessionPrefix + this.options.sessionId, {})
423
429
  const base64 = await qrCode.toDataURL()
424
430
  const modal = this.createModal(
@@ -906,6 +912,21 @@ class FloatingBlock {
906
912
  this.dropdownMenu.parentNode.removeChild(this.dropdownMenu)
907
913
  }
908
914
  }
915
+
916
+ // 隐藏组件
917
+ public hide(): void {
918
+ if (this.floatingBlock) {
919
+ this.floatingBlock.style.display = 'none'
920
+ }
921
+ this.closeDropdown()
922
+ }
923
+
924
+ // 显示组件
925
+ public show(): void {
926
+ if (this.floatingBlock) {
927
+ this.floatingBlock.style.display = 'flex'
928
+ }
929
+ }
909
930
  }
910
931
 
911
932
  // 导出组件
package/skills/index.ts CHANGED
@@ -42,21 +42,42 @@ export function parseSkillFrontMatter(content: string): { name: string; descript
42
42
  return name && description ? { name, description } : null
43
43
  }
44
44
 
45
+ /**
46
+ * 将 Vite import.meta.glob 得到的多种 key 格式统一为「相对 skills 根目录」的路径(如 ./calculator/SKILL.md),
47
+ * 以便 getSkillMdContent / getMainSkillPathByName 等能正确按 path 查找。
48
+ * 兼容任意引入位置:./skills/xxx、../skills/xxx、src/skills/xxx 等,取最后一个 skills/ 后的部分并加上 ./
49
+ */
50
+ function normalizeSkillModuleKeys(modules: Record<string, string>): Record<string, string> {
51
+ const result: Record<string, string> = {}
52
+ for (const [key, content] of Object.entries(modules)) {
53
+ const normalizedKey = key.replace(/\\/g, '/')
54
+ const skillsIndex = normalizedKey.lastIndexOf('skills/')
55
+ const relativePath = skillsIndex >= 0 ? normalizedKey.slice(skillsIndex + 7) : normalizedKey
56
+ const standardPath = relativePath.startsWith('./') ? relativePath : `./${relativePath}`
57
+ result[standardPath] = content
58
+ }
59
+ return result
60
+ }
61
+
45
62
  /**
46
63
  * 获取所有「主 SKILL.md」的路径(一级子目录下的 SKILL.md)
64
+ * - 对传入的 modules 先做 normalize,兼容任意 import.meta.glob 写法
47
65
  */
48
66
  export function getMainSkillPaths(modules: Record<string, string>): string[] {
49
- return Object.keys(modules).filter((path) => MAIN_SKILL_PATH_REG.test(path))
67
+ const normalized = normalizeSkillModuleKeys(modules)
68
+ return Object.keys(normalized).filter((path) => MAIN_SKILL_PATH_REG.test(path))
50
69
  }
51
70
 
52
71
  /**
53
72
  * 获取所有技能的概况列表(name、description、path),用于 systemPrompt 或列表展示
73
+ * - 内部统一对 modules 做 normalize,避免调用方关心路径细节
54
74
  */
55
75
  export function getSkillOverviews(modules: Record<string, string>): SkillMeta[] {
56
- const mainPaths = getMainSkillPaths(modules)
76
+ const normalized = normalizeSkillModuleKeys(modules)
77
+ const mainPaths = Object.keys(normalized).filter((path) => MAIN_SKILL_PATH_REG.test(path))
57
78
  const list: SkillMeta[] = []
58
79
  for (const path of mainPaths) {
59
- const content = modules[path]
80
+ const content = normalized[path]
60
81
  if (!content) continue
61
82
  const parsed = parseSkillFrontMatter(content)
62
83
  if (!parsed) continue
@@ -81,23 +102,57 @@ export function formatSkillsForSystemPrompt(skills: SkillMeta[]): string {
81
102
 
82
103
  /**
83
104
  * 获取所有已加载的技能文件路径(含主 SKILL.md 与 reference 下的 .md/.json/.xml 等)
105
+ * - 对 modules 做 normalize 后再返回 key 列表
84
106
  */
85
107
  export function getSkillMdPaths(modules: Record<string, string>): string[] {
86
- return Object.keys(modules)
108
+ const normalized = normalizeSkillModuleKeys(modules)
109
+ return Object.keys(normalized)
87
110
  }
88
111
 
89
112
  /**
90
113
  * 根据相对路径获取某个技能文档的原始内容(支持 .md、.json、.xml 等文本格式)
114
+ * - 自动对 modules 做 normalize,再按 path 查找
91
115
  */
92
116
  export function getSkillMdContent(modules: Record<string, string>, path: string): string | undefined {
93
- return modules[path]
117
+ const normalized = normalizeSkillModuleKeys(modules)
118
+
119
+ // 1. 尝试原有的严格匹配
120
+ const exactMatch = normalized[path]
121
+ if (exactMatch) return exactMatch
122
+
123
+ // 2. 降级匹配:如果严格匹配完整路径未找到
124
+ // 则尝试寻找后缀能够匹配上的真实文件路径。
125
+ // 去除开头的 '.' 或 './' 以精确匹配结尾部分的路径。
126
+ const suffix = path.replace(/^\.?\//, '/')
127
+ const matchingKey = Object.keys(normalized).find((key) => key.endsWith(suffix))
128
+ return matchingKey ? normalized[matchingKey] : undefined
94
129
  }
95
130
 
96
131
  /**
97
- * 根据技能 name 查找其主 SKILL.md 的路径(name 与目录名一致)
132
+ * 根据技能 name 查找其主 SKILL.md 的路径
133
+ * 支持匹配目录名(如 ecommerce)或 SKILL.md 内 frontmatter 定义的 name
134
+ * - 依赖 getMainSkillPaths,内部已做 normalize
98
135
  */
99
136
  export function getMainSkillPathByName(modules: Record<string, string>, name: string): string | undefined {
100
- return getMainSkillPaths(modules).find((p) => p.startsWith(`./${name}/SKILL.md`))
137
+ const normalizedModules = normalizeSkillModuleKeys(modules)
138
+ const paths = getMainSkillPaths(normalizedModules)
139
+
140
+ // 1. 先尝试按目录名精确匹配 (兼容老逻辑)
141
+ const dirMatch = paths.find((p) => p.startsWith(`./${name}/SKILL.md`))
142
+ if (dirMatch) return dirMatch
143
+
144
+ // 2. 如果按目录名找不到,则解析内容按 frontmatter 的 name 匹配
145
+ for (const p of paths) {
146
+ const content = normalizedModules[p]
147
+ if (content) {
148
+ const parsed = parseSkillFrontMatter(content)
149
+ if (parsed && parsed.name === name) {
150
+ return p
151
+ }
152
+ }
153
+ }
154
+
155
+ return undefined
101
156
  }
102
157
 
103
158
  // ============ 内置工具:供 remoter 注入,替代业界 skill 中「读取文档」的操作 ============
@@ -106,32 +161,104 @@ export function getMainSkillPathByName(modules: Record<string, string>, name: st
106
161
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
162
  export type SkillToolsSet = Record<string, any>
108
163
 
164
+ // 提升为模块级常量:避免 tool() 推断 PARAMETERS 泛型时递归展开 Zod 链导致"类型实例化过深"
165
+ const SKILL_INPUT_SCHEMA = z.object({
166
+ skillName: z
167
+ .string()
168
+ .optional()
169
+ .describe(
170
+ '进入某个技能的主入口名称。优先匹配技能的目录名(如 ecommerce),或者技能的中文名称(如"客户价保单创建及审核")。'
171
+ ),
172
+ path: z
173
+ .string()
174
+ .optional()
175
+ .describe('你想查阅的文档的路径。如 ./calculator/SKILL.md 或从其他文档里看到的相对路径 ./reference/inventory.md。'),
176
+ currentPath: z
177
+ .string()
178
+ .optional()
179
+ .describe(
180
+ '你当前正在阅读的文档路径(如果有)。比如你刚刚读取了 ./ecommerce/SKILL.md,请把这个路径原样传回来,这样系统才能根据你的相对路径准确找到下一份文件。'
181
+ )
182
+ })
183
+
109
184
  /**
110
185
  * 根据 skillMdModules 创建供 AI 调用的工具集
111
186
  * - get_skill_content: 按技能名或路径获取完整文档内容,便于大模型自动识别并加载技能
112
187
  * remoter 可将返回的 tools 合并进 extraTools 注入 agent
113
188
  */
114
189
  export function createSkillTools(modules: Record<string, string>): SkillToolsSet {
190
+ const normalizedModules = normalizeSkillModuleKeys(modules)
191
+
192
+ // @ts-ignore ai package 的 tool() 函数类型推断存在"类型实例化过深"的已知限制,无法正确推断包含复杂 Zod 链的 schema
115
193
  const getSkillContent = tool({
116
194
  description:
117
- '根据技能名称或文档路径获取该技能的完整文档内容。传入 skillName(如 calculator)或 path(如 ./calculator/SKILL.md)。支持 .md、.json、.xml 等各类文本格式文件。',
118
- inputSchema: z.object({
119
- skillName: z.string().optional().describe('技能名称,与目录名一致,如 calculator'),
120
- path: z.string().optional().describe('文档相对路径,如 ./calculator/SKILL.md ./product-guide/reference/xxx.json')
121
- }),
122
- execute: (args: { skillName?: string; path?: string }) => {
123
- const { skillName, path: pathArg } = args
195
+ '根据技能名称或文档路径获取该技能的完整文档内容。如果你想根据相对路径查阅文件,请务必同时提供你当前所在的文件路径 currentPath。',
196
+ inputSchema: SKILL_INPUT_SCHEMA,
197
+ execute: (args: { skillName?: string; path?: string; currentPath?: string }): Record<string, unknown> => {
198
+ const { skillName, path: pathArg, currentPath: currentPathArg } = args
124
199
  let content: string | undefined
200
+ let resolvedPath = ''
201
+
125
202
  if (pathArg) {
126
- content = getSkillMdContent(modules, pathArg)
203
+ // 使用明确提供的当前阅读上下文作为基准路径(默认在根目录)
204
+ let basePathContext = '.'
205
+ if (currentPathArg) {
206
+ // 提取出当前文档所在的目录
207
+ // 比如 ./ecommerce/SKILL.md -> ./ecommerce
208
+ const lastSlashIndex = currentPathArg.lastIndexOf('/')
209
+ if (lastSlashIndex >= 0) {
210
+ basePathContext = currentPathArg.slice(0, lastSlashIndex)
211
+ }
212
+ }
213
+
214
+ // 尝试 1:按照大模型当前提供的上下文进行标准相对路径解析
215
+ const dummyBase = `http://localhost/${basePathContext}/`
216
+ const url = new URL(pathArg, dummyBase)
217
+ resolvedPath = '.' + url.pathname
218
+ content = getSkillMdContent(normalizedModules, resolvedPath)
219
+
220
+ // 尝试 2:如果大模型忘了传正确的 currentPath,或者是强行传错,做个智能根目录回退
221
+ if (content === undefined && (pathArg.startsWith('./') || pathArg.startsWith('../')) && currentPathArg) {
222
+ const baseParts = currentPathArg.split('/')
223
+ if (baseParts.length >= 2) {
224
+ const skillRoot = baseParts[1]
225
+ const fallbackDummyBase = `http://localhost/${skillRoot}/`
226
+ const fallbackUrl = new URL(pathArg, fallbackDummyBase)
227
+ const fallbackPath = '.' + fallbackUrl.pathname
228
+ content = getSkillMdContent(normalizedModules, fallbackPath)
229
+ if (content) {
230
+ resolvedPath = fallbackPath
231
+ }
232
+ }
233
+ }
234
+
235
+ // 尝试 3:后缀自动降级匹配修正
236
+ if (content && !normalizedModules[resolvedPath]) {
237
+ const suffix = resolvedPath.replace(/^\.?\//, '/')
238
+ const matchingKey = Object.keys(normalizedModules).find((key) => key.endsWith(suffix))
239
+ if (matchingKey) {
240
+ resolvedPath = matchingKey
241
+ }
242
+ }
127
243
  } else if (skillName) {
128
- const mainPath = getMainSkillPathByName(modules, skillName)
129
- content = mainPath ? getSkillMdContent(modules, mainPath) : undefined
244
+ const mainPath = getMainSkillPathByName(normalizedModules, skillName)
245
+ if (mainPath) {
246
+ resolvedPath = mainPath
247
+ content = getSkillMdContent(normalizedModules, mainPath)
248
+ }
130
249
  }
250
+
131
251
  if (content === undefined) {
132
- return { error: '未找到对应技能文档', skillName: skillName ?? pathArg }
252
+ return {
253
+ error: '未找到对应技能文档',
254
+ skillName,
255
+ path: pathArg,
256
+ providedCurrentPath: currentPathArg,
257
+ attemptedPath: resolvedPath
258
+ }
133
259
  }
134
- return { content, path: pathArg ?? getMainSkillPathByName(modules, skillName!) }
260
+
261
+ return { content, path: resolvedPath }
135
262
  }
136
263
  })
137
264