@uwa4d/openapi-mcp 0.2.0-beta.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ UWA 开放平台 MCP Server。把 UWA Open API 暴露为 MCP 工具,让 Cursor
40
40
  | Claude Desktop | `claude_desktop_config.json` |
41
41
  | Trae | 设置面板中的 MCP 配置 |
42
42
 
43
- 改完重启客户端即可。
43
+ 改完重启客户端即可。多数客户端(Claude Desktop、Trae,以及 PATH 正常的环境)按上面配置即可,**一般不用写 node / npx 绝对路径**。
44
44
 
45
45
  凭证也可以走命令行参数,适配不支持 `env` 字段的客户端:
46
46
 
@@ -57,6 +57,73 @@ UWA 开放平台 MCP Server。把 UWA Open API 暴露为 MCP 工具,让 Cursor
57
57
 
58
58
  > 配置文件里含有凭证,注意不要提交到公开代码仓库。
59
59
 
60
+ ### Cursor 连不上时(按系统)
61
+
62
+ 先在**本机终端**跑通 `npx -y @uwa4d/openapi-mcp check ...`。终端正常、仅 Cursor 失败时,多半是 Cursor 启动 MCP 的环境问题(**不是本包或凭证坏了**)。按日志选对应写法,仍用 `npx`,不必绑死本机 Node 安装路径。
63
+
64
+ | 系统 | 常见日志 | 推荐改法 |
65
+ | --- | --- | --- |
66
+ | **macOS** | `ENOENT .../Cursor.app/.../resources/lib` | 用登录壳启动(见下「macOS」) |
67
+ | **Windows** | `spawn npx ENOENT` | 用 `cmd /c` 包一层(见下「Windows」) |
68
+ | **Linux** | 找不到 `npx` / 类似 PATH 问题 | 用 `bash -lc`(见下「Linux」) |
69
+
70
+ **macOS(推荐)**
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "uwa-openapi": {
76
+ "command": "/bin/zsh",
77
+ "args": ["-lic", "npx -y @uwa4d/openapi-mcp mcp"],
78
+ "env": {
79
+ "UWA_MCP_APP_ID": "<your_app_id>",
80
+ "UWA_MCP_APP_SECRET": "<your_app_secret>"
81
+ }
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ 说明:Cursor 可能把自带 Node 插进 `PATH`,导致 `npx` 找错运行时。`-lic` 走登录壳,加载你平时终端里的 Node(含 nvm 等),不写死版本路径。额外参数写进同一条命令字符串即可。
88
+
89
+ **Windows**
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "uwa-openapi": {
95
+ "command": "cmd",
96
+ "args": ["/c", "npx", "-y", "@uwa4d/openapi-mcp", "mcp"],
97
+ "env": {
98
+ "UWA_MCP_APP_ID": "<your_app_id>",
99
+ "UWA_MCP_APP_SECRET": "<your_app_secret>"
100
+ }
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ 说明:Windows 上 `npx` 实际是 `npx.cmd`,Cursor 直接 `spawn('npx')` 常会 `ENOENT`。经 `cmd /c` 启动即可。请先确认系统已安装 Node 18+,且在 **CMD** 里执行 `npx -v` 成功(若只用 nvm-windows / fnm,需保证其 bin 已进系统 PATH,或改用该工具提供的 `exec` 方式)。
107
+
108
+ **Linux**
109
+
110
+ ```json
111
+ {
112
+ "mcpServers": {
113
+ "uwa-openapi": {
114
+ "command": "/bin/bash",
115
+ "args": ["-lc", "npx -y @uwa4d/openapi-mcp mcp"],
116
+ "env": {
117
+ "UWA_MCP_APP_ID": "<your_app_id>",
118
+ "UWA_MCP_APP_SECRET": "<your_app_secret>"
119
+ }
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ 改完后在 Cursor 中 **Restart MCP**(或重启客户端)。若仍失败,把 MCP 输出日志里的完整报错留给支持同学。
126
+
60
127
  ### 先验证一下
61
128
 
62
129
  配置前可以在终端确认凭证和网络是否通:
@@ -28,7 +28,21 @@ export declare function hasCurveFilter(filter: CurveFilterOptions): boolean;
28
28
  * 对 y_axis 曲线做客户端过滤,解决「慢帧列表必须全量进上下文」的问题。
29
29
  */
30
30
  export declare function filterCurveData(data: unknown, filter: CurveFilterOptions): unknown;
31
+ /** returnFramesOnly 时去掉顶层无用 x_axis,避免慢帧列表撑爆上下文。 */
32
+ export declare function stripOuterXAxisWhenFramesOnly(data: unknown, filter: CurveFilterOptions): unknown;
31
33
  export declare function isIndicatorStatisticDashboard(opPath: string): boolean;
32
34
  export declare function isIndicatorCurveDashboard(opPath: string): boolean;
35
+ /** 从 android_memory_pss 统计抽出弱模型友好的峰值字段(单位 KB)。 */
36
+ export declare function extractPssPeakCard(data: unknown): Record<string, unknown> | null;
37
+ /**
38
+ * 从 Overview 统计 v2(categories[])抽出 PSS 峰值答题卡。
39
+ * v2 通常只有 maximum、没有 peak frame —— frame 为 null 时提示改用 indicator 面板。
40
+ */
41
+ export declare function extractPssPeakFromOverviewStatistic(data: unknown): Record<string, unknown> | null;
42
+ /** Overview 统计(v1/v2)成功返回后附加 PSS 答题卡。 */
43
+ export declare function annotateOverviewStatisticPss(data: unknown): unknown;
44
+ export declare function isOverviewStatisticOp(opId: string, _opPath?: string): boolean;
45
+ /** 卡顿合并树:返回层硬门禁,防止 WaitForVsync→根因 / 空 Bound 仍说 GPU 主瓶颈。 */
46
+ export declare function stutterRootCauseGate(opId: string): Record<string, unknown> | null;
33
47
  /** 统计面板成功返回后的统一标注管线。 */
34
48
  export declare function annotateStatisticDashboard(data: unknown, accepted: string[], unknown: string[]): unknown;
@@ -254,12 +254,148 @@ export function filterCurveData(data, filter) {
254
254
  _curveFilterHint: '已按 valueGt/valueLt/topN/returnFramesOnly 在 MCP 侧过滤曲线点;完整曲线去掉这些参数即可。',
255
255
  };
256
256
  }
257
+ /** returnFramesOnly 时去掉顶层无用 x_axis,避免慢帧列表撑爆上下文。 */
258
+ export function stripOuterXAxisWhenFramesOnly(data, filter) {
259
+ if (!filter.returnFramesOnly || !isObj(data))
260
+ return data;
261
+ const { x_axis: _drop, ...rest } = data;
262
+ return {
263
+ ...rest,
264
+ _framesOnlyNote: 'returnFramesOnly=true 已省略顶层 x_axis;帧号在各曲线 frames 字段。',
265
+ };
266
+ }
257
267
  export function isIndicatorStatisticDashboard(opPath) {
258
268
  return opPath.includes('/indicator/statistic/dashboard') || opPath.includes('/custom/dashboard');
259
269
  }
260
270
  export function isIndicatorCurveDashboard(opPath) {
261
271
  return opPath.includes('/indicator/curve/dashboard') || opPath.includes('/gpu/curve');
262
272
  }
273
+ function numFromUnitOrData(v) {
274
+ if (typeof v === 'number' && Number.isFinite(v))
275
+ return v;
276
+ if (Array.isArray(v) && typeof v[0] === 'number' && Number.isFinite(v[0]))
277
+ return v[0];
278
+ if (typeof v === 'object' && v !== null) {
279
+ const o = v;
280
+ if (typeof o.value === 'number' && Number.isFinite(o.value))
281
+ return o.value;
282
+ if (Array.isArray(o.data) && typeof o.data[0] === 'number')
283
+ return o.data[0];
284
+ }
285
+ return null;
286
+ }
287
+ function buildPssPeakCard(peakKb, frame) {
288
+ return {
289
+ pss_peak_kb: peakKb,
290
+ pss_peak_mb: Math.round((peakKb / 1024) * 100) / 100,
291
+ pss_peak_frame: frame,
292
+ pss_unit: 'KB',
293
+ _pssHint: 'Android PSS 峰值请优先读本对象顶层 pss_peak_kb / pss_peak_mb / pss_peak_frame;' +
294
+ '合法面板名是 android_memory_pss@max(不是 android_pss_max)。单位默认 KB。',
295
+ };
296
+ }
297
+ /** 从 android_memory_pss 统计抽出弱模型友好的峰值字段(单位 KB)。 */
298
+ export function extractPssPeakCard(data) {
299
+ if (!isObj(data))
300
+ return null;
301
+ const statistic = isObj(data['statistic']) ? data['statistic'] : null;
302
+ const nested = statistic && isObj(statistic['android_memory_pss'])
303
+ ? statistic['android_memory_pss']
304
+ : null;
305
+ const peakKb = (nested ? numFromUnitOrData(nested['maximum']) : null) ??
306
+ (statistic ? numFromUnitOrData(statistic['android_memory_pss_maximum']) : null) ??
307
+ numFromUnitOrData(data['android_memory_pss_maximum']);
308
+ const frame = (nested ? numFromUnitOrData(nested['maximum_frame']) : null) ??
309
+ (statistic ? numFromUnitOrData(statistic['android_memory_pss_maximum_frame']) : null) ??
310
+ numFromUnitOrData(data['android_memory_pss_maximum_frame']);
311
+ if (peakKb == null)
312
+ return null;
313
+ return buildPssPeakCard(peakKb, frame);
314
+ }
315
+ /**
316
+ * 从 Overview 统计 v2(categories[])抽出 PSS 峰值答题卡。
317
+ * v2 通常只有 maximum、没有 peak frame —— frame 为 null 时提示改用 indicator 面板。
318
+ */
319
+ export function extractPssPeakFromOverviewStatistic(data) {
320
+ if (!isObj(data))
321
+ return null;
322
+ const categories = Array.isArray(data['categories'])
323
+ ? data['categories']
324
+ : isObj(data['data']) && Array.isArray(data['data']['categories'])
325
+ ? data['data']['categories']
326
+ : null;
327
+ if (!categories)
328
+ return null;
329
+ let peakKb = null;
330
+ let frame = null;
331
+ for (const cat of categories) {
332
+ if (!isObj(cat) || !Array.isArray(cat['indicators']))
333
+ continue;
334
+ for (const ind of cat['indicators']) {
335
+ if (!isObj(ind))
336
+ continue;
337
+ const key = String(ind['key'] ?? ind['indicatorKey'] ?? '');
338
+ const n = numFromUnitOrData(ind['data'] ?? ind['value']);
339
+ if (n == null)
340
+ continue;
341
+ if (key === 'android_memory_pss_maximum' || key === 'android_memory_pss@max')
342
+ peakKb = n;
343
+ if (key === 'android_memory_pss_maximum_frame' || key === 'android_memory_pss@max_frame')
344
+ frame = n;
345
+ }
346
+ }
347
+ if (peakKb == null)
348
+ return null;
349
+ const card = buildPssPeakCard(peakKb, frame);
350
+ if (frame == null) {
351
+ card['_pssFrameNote'] =
352
+ '本接口未返回峰值帧;需要 pss_peak_frame 时请再调 gotonline_overview_indicator_statistic_dashboard,面板名 android_memory_pss@max。';
353
+ }
354
+ return card;
355
+ }
356
+ /** Overview 统计(v1/v2)成功返回后附加 PSS 答题卡。 */
357
+ export function annotateOverviewStatisticPss(data) {
358
+ if (Array.isArray(data)) {
359
+ return data.map((item) => annotateOverviewStatisticPss(item));
360
+ }
361
+ if (!isObj(data))
362
+ return data;
363
+ // batch map: { [dataKey]: { categories... } }
364
+ const keys = Object.keys(data);
365
+ if (!data['categories'] &&
366
+ !data['statistic'] &&
367
+ !data['brief'] &&
368
+ keys.length > 0 &&
369
+ keys.every((k) => isObj(data[k]))) {
370
+ const next = { ...data };
371
+ for (const k of keys)
372
+ next[k] = annotateOverviewStatisticPss(data[k]);
373
+ return next;
374
+ }
375
+ if (data['pss_peak_kb'] != null)
376
+ return data;
377
+ const fromDash = extractPssPeakCard(data);
378
+ const fromV2 = fromDash ?? extractPssPeakFromOverviewStatistic(data);
379
+ if (!fromV2)
380
+ return data;
381
+ return { ...data, ...fromV2 };
382
+ }
383
+ export function isOverviewStatisticOp(opId, _opPath) {
384
+ return (opId === 'get_overview_statistic_v1' ||
385
+ opId === 'get_overview_statistic_v2' ||
386
+ opId === 'get_ue_overview_statistic_v2');
387
+ }
388
+ /** 卡顿合并树:返回层硬门禁,防止 WaitForVsync→根因 / 空 Bound 仍说 GPU 主瓶颈。 */
389
+ export function stutterRootCauseGate(opId) {
390
+ if (!/stack_stutter_(full|scene)_tree/.test(opId))
391
+ return null;
392
+ return {
393
+ forbidden_claims: ['WaitForVsync是卡顿根因', 'GPU是主要瓶颈', 'GPU Bound明显', 'GPU主瓶颈'],
394
+ _stutterRootCauseHint: '本树是卡顿根因主入口。请按耗时归因到具体业务/引擎节点;' +
395
+ '禁止把 WaitForVsync / Gfx.WaitForPresent 写成根因或 GPU Bound 证据。' +
396
+ '若要说 GPU 主瓶颈,必须另查 gotonline_gpu_bound;空数组只能说「未检出 Bound」。',
397
+ };
398
+ }
263
399
  /** 统计面板成功返回后的统一标注管线。 */
264
400
  export function annotateStatisticDashboard(data, accepted, unknown) {
265
401
  let out = annotateSceneUnits(data);
@@ -267,8 +403,10 @@ export function annotateStatisticDashboard(data, accepted, unknown) {
267
403
  out = annotateParamFieldMap(out);
268
404
  if (!isObj(out))
269
405
  return out;
406
+ const pssCard = extractPssPeakCard(out);
270
407
  return {
271
408
  ...out,
409
+ ...(pssCard ?? {}),
272
410
  _acceptedDashboards: accepted,
273
411
  ...(unknown.length ? { _unknownDashboards: unknown } : {}),
274
412
  };
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { compositeFilterKeys, selectCompositeTools, COMPOSITE_TOOLS } from './co
6
6
  import { filterKeys, selectOperations } from './presets.js';
7
7
  import { DEFAULT_MAX_CHARS, DEFAULT_MAX_ROWS, toolName } from './tools.js';
8
8
  import { PACKAGE_VERSION, startStdio } from './server.js';
9
+ import { checkForUpdate, describeReleaseChannel, formatUpdateNotice } from './version-check.js';
9
10
  const DEFAULT_BASE_URL = 'https://secure-api.uwa4d.com';
10
11
  const SANDBOX_BASE_URL = 'https://sandbox-api.uwa4d.com';
11
12
  function splitList(value) {
@@ -133,6 +134,16 @@ program
133
134
  console.log('\n凭证有效,接口连通。');
134
135
  if (typeof total === 'number')
135
136
  console.log(`最近 7 天有 ${total} 份报告。`);
137
+ console.log(`\nMCP 包版本:${PACKAGE_VERSION}(${describeReleaseChannel(PACKAGE_VERSION).releaseLabel})`);
138
+ if (process.env['UWA_MCP_SKIP_UPDATE_CHECK'] !== '1') {
139
+ const update = await checkForUpdate(PACKAGE_VERSION);
140
+ if (update) {
141
+ console.log(formatUpdateNotice(update));
142
+ }
143
+ else {
144
+ console.log('已是最新同 release 线版本(或暂时无法连接 npm registry)。');
145
+ }
146
+ }
136
147
  console.log('\n可以按 README 配置 MCP 客户端了。');
137
148
  }
138
149
  catch (err) {
package/dist/client.js CHANGED
@@ -1,22 +1,12 @@
1
1
  import { gunzipSync } from 'node:zlib';
2
2
  import { authHeaders } from './auth.js';
3
+ import { resolveErrorHint } from './error-hints.js';
3
4
  function isEnvelope(v) {
4
5
  if (!v || typeof v !== 'object' || Array.isArray(v))
5
6
  return false;
6
7
  const o = v;
7
8
  return o['status'] === 'success' || o['status'] === 'failed';
8
9
  }
9
- /** 常见错误码的排查提示,直接给到模型,省掉一轮试错。 */
10
- const ERROR_HINTS = {
11
- 20001: '业务参数不合法,核对参数名、取值范围和必填项(注意批量接口的参数名多为复数,如 dataKeys)',
12
- 23508: '数据服务错误,常见原因是报告不适用该接口版本(如新报告调用了 1.0 接口,或旧报告调用了 2.0 接口),改用对应版本重试',
13
- 24050: '该账号未开通 Open API 权限,请联系 UWA 工作人员开通',
14
- 24052: '请求参数有误,请对照接口文档检查',
15
- 24054: 'AppId 不存在,检查 appId 是否正确、是否用错了环境(sandbox / 线上凭证不通用)',
16
- 24056: '签名错误,检查 appSecret 是否正确',
17
- 24057: '时间戳过期,本机时间与服务端偏差不能超过 20 分钟',
18
- 30001: '服务端错误,常见原因是用错了引擎对应的接口(如对 UE 报告调用了 Unity 专用接口)',
19
- };
20
10
  export class UwaApiError extends Error {
21
11
  code;
22
12
  rawMessage;
@@ -68,8 +58,9 @@ export class UwaClient {
68
58
  if (parsed.status === 'failed' || parsed.error) {
69
59
  const code = parsed.error?.code ?? -1;
70
60
  const raw = parsed.error?.data?.rawMessage ?? '';
71
- const hint = ERROR_HINTS[code];
72
- throw new UwaApiError(code, raw, `[${code}] ${parsed.error?.message ?? '请求失败'}${raw ? ` (${raw})` : ''}${hint ? `\n排查建议:${hint}` : ''}`);
61
+ const apiMessage = parsed.error?.message ?? '请求失败';
62
+ const hint = resolveErrorHint(code, raw, apiMessage);
63
+ throw new UwaApiError(code, raw, `[${code}] ${apiMessage}${raw ? ` (${raw})` : ''}${hint ? `\n排查建议:${hint}` : ''}`);
73
64
  }
74
65
  return parsed.data ?? {};
75
66
  }
@@ -2,8 +2,10 @@ import { toolName } from '../tools.js';
2
2
  import { topResourcesTool } from './top-resources.js';
3
3
  import { topFunctionsTool } from './top-functions.js';
4
4
  import { reportDiagnosisTool } from './report-diagnosis.js';
5
+ import { mcpVersionTool } from './mcp-version.js';
5
6
  /** 全部手写复合工具(不进 uwa-openapi.json)。 */
6
7
  export const COMPOSITE_TOOLS = [
8
+ mcpVersionTool,
7
9
  topResourcesTool,
8
10
  topFunctionsTool,
9
11
  reportDiagnosisTool,
@@ -0,0 +1,2 @@
1
+ import type { CompositeTool } from './types.js';
2
+ export declare const mcpVersionTool: CompositeTool;
@@ -0,0 +1,48 @@
1
+ import { PACKAGE_VERSION } from '../package-version.js';
2
+ import { buildMcpVersionInfo, describeReleaseChannel } from '../version-check.js';
3
+ import { errorResult, jsonToolResult } from './helpers.js';
4
+ const release = describeReleaseChannel(PACKAGE_VERSION);
5
+ const STABLE_DESCRIPTION = [
6
+ '查询当前 UWA OpenAPI MCP 正式版版本,并与 npm 最新稳定版对比(仅正式版↔正式版,不与内部测试版交叉)。',
7
+ '适用:Unity / UE|复合工具|无需 dataKey',
8
+ '触发:本对话第一次使用任意 UWA MCP 工具前应先调一次;用户问版本/升级时也可调。同一对话勿重复。',
9
+ '若 updateAvailable=true / suggestTellUser=true,请用 userNotice 主动转告客户重启 MCP 升级;已是最新勿打扰。',
10
+ '不要臆测版本号。',
11
+ ].join('\n');
12
+ const INTERNAL_DESCRIPTION = [
13
+ '查询当前 UWA OpenAPI MCP 内部测试版版本,并与 npm @beta 最新测试版对比(仅测试↔测试,不与正式版交叉)。',
14
+ '适用:Unity / UE|复合工具|无需 dataKey|仅 UWA 内部 sandbox 联调',
15
+ '触发:本对话第一次使用任意 UWA MCP 工具前应先调一次;用户问版本/升级时也可调。同一对话勿重复。',
16
+ '若 updateAvailable=true / suggestTellUser=true,请用 userNotice 主动转告测试人员重启 MCP 升级;已是最新勿打扰。',
17
+ '不要臆测版本号。',
18
+ ].join('\n');
19
+ export const mcpVersionTool = {
20
+ id: 'uwa_mcp_version',
21
+ title: release.releaseLine === 'stable' ? 'MCP 正式版与升级提示' : 'MCP 内部测试版与升级提示',
22
+ engines: ['unity', 'unreal'],
23
+ filterKeys: ['uwa_mcp_version', 'composite', 'preset.default', 'common'],
24
+ description: release.releaseLine === 'stable' ? STABLE_DESCRIPTION : INTERNAL_DESCRIPTION,
25
+ inputSchema: {},
26
+ async handler(_args, ctx) {
27
+ try {
28
+ if (process.env['UWA_MCP_SKIP_UPDATE_CHECK'] === '1') {
29
+ return jsonToolResult({
30
+ package: '@uwa4d/openapi-mcp',
31
+ currentVersion: PACKAGE_VERSION,
32
+ releaseLine: release.releaseLine,
33
+ releaseLabel: release.releaseLabel,
34
+ updateCheckSkipped: true,
35
+ userNotice: `当前 MCP ${release.releaseLabel} ${PACKAGE_VERSION}(已设置 UWA_MCP_SKIP_UPDATE_CHECK,未核对 npm 最新版)。`,
36
+ }, ctx.maxChars);
37
+ }
38
+ const info = await buildMcpVersionInfo(PACKAGE_VERSION);
39
+ return jsonToolResult({
40
+ ...info,
41
+ suggestTellUser: info.updateAvailable,
42
+ }, ctx.maxChars);
43
+ }
44
+ catch (err) {
45
+ return errorResult(err);
46
+ }
47
+ },
48
+ };
@@ -1,9 +1,10 @@
1
1
  import { z } from './types.js';
2
- import { errorResult, jsonToolResult, requireReportKey } from './helpers.js';
2
+ import { errorResult, isObj, jsonToolResult, requireReportKey } from './helpers.js';
3
3
  import { fetchOverviewStatistic, fetchReportIdentity, fetchSceneStatistic, } from './route-overview.js';
4
4
  import { viewOverview } from './overview-view.js';
5
5
  import { runTopFunctions } from './top-functions.js';
6
6
  import { runTopResources } from './top-resources.js';
7
+ import { extractPssPeakCard } from '../annotate-dashboard.js';
7
8
  function summarizeScenes(data) {
8
9
  if (!data)
9
10
  return { sceneCount: 0 };
@@ -76,18 +77,109 @@ function pickImportantBrief(brief) {
76
77
  }
77
78
  return out;
78
79
  }
80
+ function numOf(v) {
81
+ if (typeof v === 'number' && Number.isFinite(v))
82
+ return v;
83
+ if (Array.isArray(v) && typeof v[0] === 'number')
84
+ return v[0];
85
+ if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v)))
86
+ return Number(v);
87
+ return null;
88
+ }
89
+ /** 从 overview brief/summary 尽量抽出 PSS 峰值答题卡。 */
90
+ function extractPssFromView(brief, summary) {
91
+ const sources = [brief, summary];
92
+ for (const src of sources) {
93
+ const peak = numOf(src['android_memory_pss_maximum']) ??
94
+ numOf(src['android_memory_pss@max']) ??
95
+ numOf(src['PSS峰值']) ??
96
+ numOf(src['设备内存峰值(MB)']);
97
+ const frame = numOf(src['android_memory_pss_maximum_frame']) ??
98
+ numOf(src['android_memory_pss@max_frame']) ??
99
+ null;
100
+ if (peak == null)
101
+ continue;
102
+ // brief 里设备内存峰值多为 MB;android_memory_pss 多为 KB
103
+ const looksMb = peak < 50_000 && (src['设备内存峰值(MB)'] !== undefined || peak < 2048);
104
+ const peakKb = looksMb ? Math.round(peak * 1024) : peak;
105
+ return {
106
+ pss_peak_kb: peakKb,
107
+ pss_peak_mb: Math.round((peakKb / 1024) * 100) / 100,
108
+ pss_peak_frame: frame,
109
+ pss_unit: 'KB',
110
+ _pssHint: 'Android PSS 峰值请优先读 pss_peak_kb/mb/frame;合法面板名 android_memory_pss@max(不是 android_pss_max)。',
111
+ };
112
+ }
113
+ return null;
114
+ }
115
+ /** 优先走 indicator statistic 拿完整 PSS 峰值+帧;失败再回退 overview brief。 */
116
+ async function resolvePssPeakCard(client, identity, brief, summary, apisUsed, limitations) {
117
+ const keyQuery = {
118
+ indicatorDashboards: 'android_memory_pss@max',
119
+ };
120
+ if (identity.dataKey)
121
+ keyQuery['dataKey'] = identity.dataKey;
122
+ else if (identity.recordId != null)
123
+ keyQuery['recordId'] = identity.recordId;
124
+ else
125
+ return extractPssFromView(brief, summary);
126
+ try {
127
+ const raw = await client.call('GET', '/openapi/v1/data/gotonline/overview/indicator/statistic/dashboard', 'v1.0.1', keyQuery);
128
+ apisUsed.push('gotonline_overview_indicator_statistic_dashboard');
129
+ const card = extractPssPeakCard(raw);
130
+ if (card)
131
+ return card;
132
+ limitations.push('indicator PSS 面板无峰值字段,已回退 overview 摘要');
133
+ }
134
+ catch (e) {
135
+ limitations.push(`PSS 面板查询失败,已回退 overview 摘要:${e instanceof Error ? e.message : String(e)}`);
136
+ }
137
+ return extractPssFromView(brief, summary);
138
+ }
139
+ function isWaitForVsyncName(name) {
140
+ return /wait\s*for\s*vsync|WaitForVsync|Gfx\.WaitForPresent/i.test(String(name ?? ''));
141
+ }
142
+ function buildBottleneckCandidates(topFunctions, gpuBoundEmpty) {
143
+ const items = topFunctions && Array.isArray(topFunctions['items']) ? topFunctions['items'] : [];
144
+ const out = [];
145
+ for (const raw of items.slice(0, 8)) {
146
+ if (!isObj(raw))
147
+ continue;
148
+ const name = raw['name'] ?? raw['methodName'] ?? raw['stackMethodName'];
149
+ const wait = isWaitForVsyncName(name);
150
+ out.push({
151
+ name,
152
+ selfTimeMean: raw['selfTimeMean'] ?? raw['value'] ?? null,
153
+ source: 'top_functions',
154
+ role: wait ? 'frame_sync_wait' : 'cpu_candidate',
155
+ note: wait
156
+ ? 'WaitForVsync/Present 等待是帧同步表现,不能直接当卡顿根因或 GPU Bound 证据'
157
+ : '需结合 stutter 合并树确认是否为卡顿根因',
158
+ });
159
+ }
160
+ if (gpuBoundEmpty === true) {
161
+ out.push({
162
+ name: 'GPU Bound',
163
+ source: 'gotonline_gpu_bound',
164
+ role: 'not_detected',
165
+ note: 'targetFps=30 下未检出 GPU Bound 区间',
166
+ });
167
+ }
168
+ return out;
169
+ }
79
170
  export const reportDiagnosisTool = {
80
171
  id: 'report_diagnosis',
81
172
  title: '报告体检',
82
173
  engines: ['unity', 'unreal'],
83
174
  filterKeys: ['report_diagnosis', 'composite', 'preset.default', 'overview'],
84
175
  description: [
85
- '对单份 GOT Online 报告做一次结构化体检:自动选对 Overview 统计 v1/v2,并串联 Top 资源、Top 函数与场景摘要。',
176
+ '对单份 GOT Online 报告做一次结构化体检:自动选对 Overview 统计 v1/v2,并串联 Top 资源、Top 函数、场景摘要与 GPU Bound@30。',
86
177
  '适用引擎:Unity / UE|复合工具',
87
178
  '调用方不必自己判断 2026-07-09 / 2026-03-25 分界或 SDK 版本——内部按 get_report_detail 路由。',
88
- 'Top 函数:Overview 走 method/idmap/statistic v1.0.2 + stackTestMode;Mono 走堆栈树;逐帧曲线用 method/curve/presign v1.0.2。',
89
- '查面板统计/曲线前先调 gotonline_overview_indicator_dashboard_keys 核对 indicatorDashboards(勿把 fps_mean 等统计字段当面板名)。',
90
- '卡顿根因:stutter_full_tree / stutter_scene_tree(卡顿帧已合并的树);尖峰再 stack_tree_frame;勿对每个慢帧循环拉帧树。',
179
+ '返回答题卡:forbidden_claims、bottleneck_candidates(与 topFunctions 分栏)、gpuBound、pss_peak_*、comparability_note。',
180
+ '卡顿根因:优先 stutter_full_tree / stutter_scene_tree;禁止把 WaitForVsync 写成根因或 GPU 主瓶颈。',
181
+ 'gpuBound 为空时只能说「未检出 Bound」,禁止断言 GPU 是主要瓶颈。',
182
+ '查面板统计/曲线前先调 gotonline_overview_indicator_dashboard_keys(勿把 fps_mean 等统计字段当面板名)。',
91
183
  'scenes 含 list_scenes 起止帧(sceneIdx/sceneName/frameStart/frameEnd)。',
92
184
  '返回摘要而非原始大包;需要明细时再按 _apisUsed 中的原子工具深挖。',
93
185
  ].join('\n'),
@@ -166,10 +258,44 @@ export const reportDiagnosisTool = {
166
258
  catch (e) {
167
259
  limitations.push(`场景列表(list_scenes)失败:${e instanceof Error ? e.message : String(e)}`);
168
260
  }
261
+ let gpuBound = null;
262
+ let gpuBoundEmpty = null;
263
+ try {
264
+ const boundQuery = { targetFps: 30 };
265
+ if (identity.dataKey)
266
+ boundQuery['dataKey'] = identity.dataKey;
267
+ else if (identity.recordId)
268
+ boundQuery['recordId'] = identity.recordId;
269
+ gpuBound = await ctx.client.call('GET', '/openapi/v1/data/gotonline/gpu/bound', 'v1.0.1', boundQuery);
270
+ apisUsed.push('gotonline_gpu_bound');
271
+ const intervals = Array.isArray(gpuBound)
272
+ ? gpuBound
273
+ : isObj(gpuBound) && Array.isArray(gpuBound['data'])
274
+ ? gpuBound['data']
275
+ : null;
276
+ gpuBoundEmpty = Array.isArray(intervals) ? intervals.length === 0 : null;
277
+ }
278
+ catch (e) {
279
+ limitations.push(`gpu_bound 失败:${e instanceof Error ? e.message : String(e)}`);
280
+ }
169
281
  // 仅在明确未开启时提示;hasResource === null 表示无法判定,不误报关闭
170
282
  if (view.hasResource === false) {
171
283
  limitations.push('未开启资源采集,逐资源 Top 可能为空');
172
284
  }
285
+ const forbidden_claims = [];
286
+ if (gpuBoundEmpty === true) {
287
+ forbidden_claims.push('GPU是主要瓶颈', 'GPU Bound明显', 'GPU主瓶颈');
288
+ }
289
+ forbidden_claims.push('WaitForVsync是卡顿根因');
290
+ const pssCard = await resolvePssPeakCard(ctx.client, identity, view.brief, view.summary, apisUsed, limitations);
291
+ const sceneCount = isObj(scenes) ? Number(scenes['sceneCount'] ?? 0) : 0;
292
+ const comparability_note = [
293
+ '与其它报告对比时:请核对场景名/数量、测试时长、目标帧率、SDK/引擎版本是否一致。',
294
+ sceneCount > 0 ? `本报告场景数=${sceneCount}。` : '',
295
+ '仅比较 FPS 均值不能视为严格同场景对比。',
296
+ ]
297
+ .filter(Boolean)
298
+ .join(' ');
173
299
  const result = {
174
300
  report: {
175
301
  dataKey: identity.dataKey,
@@ -185,6 +311,11 @@ export const reportDiagnosisTool = {
185
311
  hasResource: view.hasResource,
186
312
  summary: view.summary,
187
313
  brief: pickImportantBrief(view.brief),
314
+ ...(pssCard ?? {}),
315
+ gpuBound: Array.isArray(gpuBound) ? gpuBound : isObj(gpuBound) ? gpuBound['data'] ?? gpuBound : gpuBound,
316
+ gpuBoundEmpty,
317
+ forbidden_claims,
318
+ bottleneck_candidates: buildBottleneckCandidates(topFunctions, gpuBoundEmpty),
188
319
  topResources: topResources
189
320
  ? {
190
321
  items: topResources['items'],
@@ -198,12 +329,14 @@ export const reportDiagnosisTool = {
198
329
  metric: topFunctions['metric'],
199
330
  uniqueFunctions: topFunctions['uniqueFunctions'],
200
331
  note: topFunctions['_note'],
332
+ _columnNote: 'topFunctions 是耗时排行,不等于卡顿根因;根因看 stutter 合并树与 bottleneck_candidates。',
201
333
  }
202
334
  : null,
203
335
  scenes,
336
+ comparability_note,
204
337
  _apisUsed: apisUsed,
205
338
  _limitations: limitations,
206
- _note: '结构化体检摘要。细节请按 _apisUsed 调用对应原子工具。',
339
+ _note: '结构化体检摘要(含答题卡)。GPU Bound 空时禁止 GPU 主瓶颈结论;卡顿根因优先 stutter 树。细节请按 _apisUsed 调用对应原子工具。',
207
340
  };
208
341
  return jsonToolResult(result, ctx.maxChars);
209
342
  }
@@ -1,3 +1,4 @@
1
+ import { withRateLimitAnnotation } from '../error-hints.js';
1
2
  import { z } from './types.js';
2
3
  import { errorResult, isObj, jsonToolResult, requireReportKey, topNOf, } from './helpers.js';
3
4
  import { fetchOverviewStatistic, fetchReportIdentity, preferAtResourceTable, } from './route-overview.js';
@@ -193,26 +194,24 @@ export async function runTopResources(client, args) {
193
194
  apisUsed.push(...fetched.apisUsed);
194
195
  if (fetched.items.length === 0) {
195
196
  const fallbackItems = typeLevelFromOverview(view);
196
- return packResult(fallbackItems, {
197
+ return packResult(fallbackItems, withRateLimitAnnotation({
197
198
  _sourceKind: fetched.sourceKind,
198
199
  _apisUsed: apisUsed,
199
- ...(fetched.errors.length ? { _partialErrors: fetched.errors } : {}),
200
200
  _note: fallbackItems.length > 0
201
201
  ? '逐资源列表为空,已回退为 Overview 类型级峰值。'
202
202
  : '逐资源列表与类型级峰值均为空(报告可能未采集资源内存)。',
203
203
  _limitations: ['per_asset_empty'],
204
- });
204
+ }, fetched.errors.length ? fetched.errors : undefined));
205
205
  }
206
206
  const note = groupBy === 'assetType'
207
207
  ? `已按类型各取 Top ${topN}(${fetched.sourceKind},共 ${assetTypes.length} 种)。`
208
208
  : `已按内存峰值合并 ${assetTypes.length} 种资源类型(${fetched.sourceKind}),返回全局 Top ${topN}。`;
209
- return packResult(fetched.items, {
209
+ return packResult(fetched.items, withRateLimitAnnotation({
210
210
  totalCandidates: fetched.items.length,
211
211
  _sourceKind: fetched.sourceKind,
212
212
  _apisUsed: apisUsed,
213
- ...(fetched.errors.length ? { _partialErrors: fetched.errors } : {}),
214
213
  _note: note,
215
- });
214
+ }, fetched.errors.length ? fetched.errors : undefined));
216
215
  }
217
216
  function normalizeAssetTypes(raw) {
218
217
  if (Array.isArray(raw) && raw.length)
@@ -236,6 +235,7 @@ export const topResourcesTool = {
236
235
  'Unity / UE 均优先走 AT 资源总览预签名(at/resource/overall/table/presign),按 assetType 并行下载后合并排序。',
237
236
  'groupBy=merged(默认):跨类型合并后取全局 Top N;groupBy=assetType:每种类型各取 Top N,返回 byType。',
238
237
  'Unity 旧报告(解析日 < 2026-06-25)回退 memory/manage;拉不到逐资源时再回退 Overview 类型级峰值。',
238
+ '内部按 assetType 并行请求,易触发 OpenAPI 限流 31004(UWA 服务端保护,预期行为)。遇限流见 _rateLimitHint:可缩小 assetTypes、稍等重试;部分类型限流时已有 Top 仍可用。',
239
239
  '不确定报告版本时直接用本工具;需要原始全量列表时再调对应原子工具。',
240
240
  ].join('\n'),
241
241
  inputSchema: {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * OpenAPI / 下游错误码 → AI 友好排查建议。
3
+ * 产品语义按引擎在此投影;got-query 只提供稳定 rawMessage 原因码。
4
+ */
5
+ export declare const FILE_META_REASONS: {
6
+ readonly LG_META_ABSENT: "LG_META_ABSENT";
7
+ readonly LG_RUNTIME_PATH_MISSING: "LG_RUNTIME_PATH_MISSING";
8
+ readonly AT_META_ABSENT: "AT_META_ABSENT";
9
+ readonly AT_META_TYPE_INVALID: "AT_META_TYPE_INVALID";
10
+ readonly AT_META_PATH_MISSING: "AT_META_PATH_MISSING";
11
+ };
12
+ /** OpenAPI 接口限流(UWA 服务端保护,预期行为)。 */
13
+ export declare const RATE_LIMIT_ERROR_CODE = 31004;
14
+ /** 复合工具 _partialErrors 含限流时附加的说明(与 resolveErrorHint(31004) 语义一致)。 */
15
+ export declare const RATE_LIMIT_HINT: string;
16
+ /** 错误文案是否含 31004 / API_RATE_LIMIT。 */
17
+ export declare function isRateLimitMessage(text: string): boolean;
18
+ /** _partialErrors 中是否有限流项。 */
19
+ export declare function hasRateLimitInPartialErrors(errors: string[]): boolean;
20
+ /** 附带 _partialErrors;若含限流则加 _rateLimitHint。 */
21
+ export declare function withRateLimitAnnotation(extra: Record<string, unknown>, partialErrors?: string[]): Record<string, unknown>;
22
+ /** 组装给模型看的排查建议;优先 rawMessage 原因码 + OpenAPI message。 */
23
+ export declare function resolveErrorHint(code: number, rawMessage: string, apiMessage: string): string | undefined;