@uwa4d/openapi-mcp 0.2.0-beta.7 → 0.2.0-beta.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/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
  配置前可以在终端确认凭证和网络是否通:
@@ -78,7 +145,6 @@ npx -y @uwa4d/openapi-mcp check -a <app_id> -s <app_secret>
78
145
  | `--app-id` | `-a` | AppId | `UWA_MCP_APP_ID` |
79
146
  | `--app-secret` | `-s` | AppSecret | `UWA_MCP_APP_SECRET` |
80
147
  | `--base-url` | `-b` | 接口地址,默认 `https://secure-api.uwa4d.com` | `UWA_MCP_API_BASE_URL` |
81
- | `--sandbox` | | 切到测试环境 `https://sandbox-api.uwa4d.com` | |
82
148
  | `--tool` | `-t` | 加载哪些工具,逗号分隔,默认 `preset.default` | `UWA_MCP_TOOL` |
83
149
  | `--engine` | `-e` | 只加载适用于 `unity` / `unreal` 的工具,默认 `all` | `UWA_MCP_ENGINE` |
84
150
  | `--tool-name-case` | `-c` | 工具命名风格 `snake` / `camel`,默认 `snake` | |
@@ -192,9 +258,6 @@ npx -y @uwa4d/openapi-mcp list-tools -t overview -e unity
192
258
  | --- | --- | --- |
193
259
  | `@uwa4d/openapi-mcp` | 跟随最新稳定版,自动升级 | 默认,大多数用户 |
194
260
  | `@uwa4d/openapi-mcp@0.1.0` | 锁死某个版本 | 生产流水线,要求完全可复现 |
195
- | `@uwa4d/openapi-mcp@beta` | 跟随测试版 | 配合 UWA 验证新接口 |
196
-
197
- 测试版只发布在 `beta` 标签下,默认写法不会拉到测试版。
198
261
 
199
262
  ## 常见问题
200
263
 
@@ -202,7 +265,7 @@ npx -y @uwa4d/openapi-mcp list-tools -t overview -e unity
202
265
  检查 Node 版本是否 ≥ 18,以及配置文件 JSON 格式是否正确(多一个逗号就会整个失效)。改完配置需要重启客户端。
203
266
 
204
267
  **提示凭证错误**
205
- 用 `check` 命令在终端单独验证一次,排除是客户端配置传参的问题。注意测试环境和生产环境的凭证不通用。
268
+ 用 `check` 命令在终端单独验证一次,排除是客户端配置传参的问题。确认 AppId / AppSecret 无误且未过期。
206
269
 
207
270
  **返回「数据服务错误」或不确定该用 v1 还是 v2**
208
271
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 自定义面板 statistic / curve 返回体的 Agent 友好标注与过滤。
3
+ * 不改上游 Open API,只在 MCP wrapper 层消歧、降噪、提效。
4
+ */
5
+ /**
6
+ * 上游嵌套 mean/maximum/min 与扁平别名已统一为同一单位(如 frametime=ms)。
7
+ * 这里只给仍无单位标注的 scene_* 裸数组补 unit/data,便于模型直接读,不改数值。
8
+ */
9
+ export declare function annotateSceneUnits(data: unknown): unknown;
10
+ /**
11
+ * 标明扁平别名与嵌套 scene_* 的重复关系,便于模型只读一份。
12
+ */
13
+ export declare function annotateDuplicateFlatAliases(data: unknown): unknown;
14
+ /** 从 columns / group 生成入参面板名 → 出参字段映射,便于模型自检。 */
15
+ export declare function annotateParamFieldMap(data: unknown): unknown;
16
+ export interface CurveFilterOptions {
17
+ valueGt?: number;
18
+ valueLt?: number;
19
+ topN?: number;
20
+ returnFramesOnly?: boolean;
21
+ }
22
+ export declare function extractCurveFilterArgs(args: Record<string, unknown>): {
23
+ filter: CurveFilterOptions;
24
+ cleaned: Record<string, unknown>;
25
+ };
26
+ export declare function hasCurveFilter(filter: CurveFilterOptions): boolean;
27
+ /**
28
+ * 对 y_axis 曲线做客户端过滤,解决「慢帧列表必须全量进上下文」的问题。
29
+ */
30
+ export declare function filterCurveData(data: unknown, filter: CurveFilterOptions): unknown;
31
+ /** returnFramesOnly 时去掉顶层无用 x_axis,避免慢帧列表撑爆上下文。 */
32
+ export declare function stripOuterXAxisWhenFramesOnly(data: unknown, filter: CurveFilterOptions): unknown;
33
+ export declare function isIndicatorStatisticDashboard(opPath: string): boolean;
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;
47
+ /** 统计面板成功返回后的统一标注管线。 */
48
+ export declare function annotateStatisticDashboard(data: unknown, accepted: string[], unknown: string[]): unknown;
@@ -0,0 +1,413 @@
1
+ /**
2
+ * 自定义面板 statistic / curve 返回体的 Agent 友好标注与过滤。
3
+ * 不改上游 Open API,只在 MCP wrapper 层消歧、降噪、提效。
4
+ */
5
+ function isObj(v) {
6
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
7
+ }
8
+ function isUnitObj(v) {
9
+ return isObj(v) && ('unit' in v || 'value' in v || 'data' in v);
10
+ }
11
+ /**
12
+ * 上游嵌套 mean/maximum/min 与扁平别名已统一为同一单位(如 frametime=ms)。
13
+ * 这里只给仍无单位标注的 scene_* 裸数组补 unit/data,便于模型直接读,不改数值。
14
+ */
15
+ export function annotateSceneUnits(data) {
16
+ if (!isObj(data) || !isObj(data['statistic']))
17
+ return data;
18
+ const statistic = { ...data['statistic'] };
19
+ const notes = [];
20
+ for (const [key, raw] of Object.entries(statistic)) {
21
+ if (!isObj(raw))
22
+ continue;
23
+ // 扁平别名 { unit, data } 跳过;只处理嵌套子指标对象
24
+ if (Array.isArray(raw['data']) && isObj(raw['unit']) && raw['mean'] === undefined)
25
+ continue;
26
+ const metric = { ...raw };
27
+ let changed = false;
28
+ const sceneUnit = isUnitObj(metric['mean']) && isObj(metric['mean'].unit) ? metric['mean'].unit : null;
29
+ for (const [sk, sv] of Object.entries(metric)) {
30
+ if (!sk.startsWith('scene_') || !Array.isArray(sv))
31
+ continue;
32
+ if (isObj(sv[0]) && 'unit' in sv[0])
33
+ continue;
34
+ if (sk.endsWith('_frame') || sk.includes('max_frame')) {
35
+ metric[sk] = { unit: { description: '帧', value: 'frame' }, data: sv };
36
+ }
37
+ else if (sk.includes('pct') || sk.includes('rate')) {
38
+ metric[sk] = { unit: { description: '百分比', value: 'percentage' }, data: sv };
39
+ }
40
+ else if (sk.includes('per_min')) {
41
+ metric[sk] = { unit: { description: '次/分钟', value: 'times/min' }, data: sv };
42
+ }
43
+ else if (sceneUnit) {
44
+ metric[sk] = { unit: sceneUnit, data: sv };
45
+ }
46
+ else {
47
+ continue;
48
+ }
49
+ changed = true;
50
+ }
51
+ if (changed) {
52
+ statistic[key] = metric;
53
+ notes.push(key);
54
+ }
55
+ }
56
+ if (!notes.length)
57
+ return data;
58
+ return {
59
+ ...data,
60
+ statistic,
61
+ _sceneUnitNote: `已为子指标 [${notes.join(', ')}] 的 scene_* 裸数组补上 unit(与嵌套 mean.unit 对齐);数值未改动。`,
62
+ };
63
+ }
64
+ /**
65
+ * 标明扁平别名与嵌套 scene_* 的重复关系,便于模型只读一份。
66
+ */
67
+ export function annotateDuplicateFlatAliases(data) {
68
+ if (!isObj(data) || !isObj(data['statistic']))
69
+ return data;
70
+ const statistic = data['statistic'];
71
+ const dups = [];
72
+ for (const [flatKey, flatVal] of Object.entries(statistic)) {
73
+ if (!isObj(flatVal) || !Array.isArray(flatVal['data']))
74
+ continue;
75
+ const m = /^(.+)_(mean|min|maximum|maximum_frame|gt_40_ms_frame_pct|jank_time_pct|jank_frames_per_min|bigjank_frames_per_min)$/.exec(flatKey);
76
+ if (!m)
77
+ continue;
78
+ const indicator = m[1];
79
+ const kind = m[2];
80
+ const nested = statistic[indicator];
81
+ if (!isObj(nested))
82
+ continue;
83
+ const sceneKey = kind === 'mean'
84
+ ? 'scene_mean'
85
+ : kind === 'min'
86
+ ? 'scene_min'
87
+ : kind === 'maximum'
88
+ ? 'scene_max'
89
+ : kind === 'maximum_frame'
90
+ ? 'scene_max_frame'
91
+ : `scene_${kind}`;
92
+ const sceneVal = nested[sceneKey];
93
+ const sceneArr = Array.isArray(sceneVal)
94
+ ? sceneVal
95
+ : isObj(sceneVal) && Array.isArray(sceneVal['data'])
96
+ ? sceneVal['data']
97
+ : null;
98
+ if (!sceneArr)
99
+ continue;
100
+ const flatArr = flatVal['data'];
101
+ if (flatArr.length === sceneArr.length && flatArr.every((v, i) => v === sceneArr[i])) {
102
+ dups.push(`${flatKey} ≡ ${indicator}.${sceneKey}`);
103
+ }
104
+ }
105
+ if (!dups.length)
106
+ return data;
107
+ return {
108
+ ...data,
109
+ _duplicateFlatAliases: dups,
110
+ _duplicateNote: '扁平别名与嵌套 scene_* 数值相同,读嵌套结构即可,扁平 key 为兼容别名。',
111
+ };
112
+ }
113
+ /** 从 columns / group 生成入参面板名 → 出参字段映射,便于模型自检。 */
114
+ export function annotateParamFieldMap(data) {
115
+ if (!isObj(data))
116
+ return data;
117
+ const columns = data['columns'];
118
+ const group = data['group'];
119
+ if (!Array.isArray(columns) && !Array.isArray(group))
120
+ return data;
121
+ const map = {};
122
+ if (Array.isArray(group)) {
123
+ for (const row of group) {
124
+ if (!Array.isArray(row) || row.length < 2)
125
+ continue;
126
+ const panel = String(row[0]);
127
+ const inds = Array.isArray(row[1]) ? row[1].map(String) : [];
128
+ if (!map[panel])
129
+ map[panel] = { indicators: [], flatKeys: [], nestedHint: [] };
130
+ map[panel].indicators = [...new Set([...map[panel].indicators, ...inds])];
131
+ for (const ind of inds) {
132
+ map[panel].nestedHint.push(`statistic.${ind}`);
133
+ }
134
+ }
135
+ }
136
+ if (Array.isArray(columns)) {
137
+ for (const col of columns) {
138
+ if (!isObj(col))
139
+ continue;
140
+ const panel = String(col['firstLevel'] ?? '');
141
+ const seconds = Array.isArray(col['secondLevel']) ? col['secondLevel'].map(String) : [];
142
+ if (!panel)
143
+ continue;
144
+ if (!map[panel])
145
+ map[panel] = { indicators: [], flatKeys: [], nestedHint: [] };
146
+ map[panel].flatKeys = [...new Set([...map[panel].flatKeys, ...seconds])];
147
+ for (const k of seconds) {
148
+ map[panel].nestedHint.push(`statistic.${k}`);
149
+ }
150
+ }
151
+ }
152
+ // frametime 专属:入参与出参字段名不一致
153
+ for (const [panel, info] of Object.entries(map)) {
154
+ if (info.indicators.includes('frametime') || panel.includes('frametime') || /jank/i.test(panel)) {
155
+ info.nestedHint.push('statistic.frametime.gt_40_ms_frame_pct', 'statistic.frametime.jank_time_pct', 'statistic.frametime.jank_frames_per_min', 'statistic.frametime.bigjank_frames_per_min');
156
+ }
157
+ info.nestedHint = [...new Set(info.nestedHint)];
158
+ }
159
+ if (!Object.keys(map).length)
160
+ return data;
161
+ return { ...data, _paramFieldMap: map };
162
+ }
163
+ function parseNum(v) {
164
+ if (typeof v === 'number' && Number.isFinite(v))
165
+ return v;
166
+ if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v)))
167
+ return Number(v);
168
+ return undefined;
169
+ }
170
+ export function extractCurveFilterArgs(args) {
171
+ const filter = {
172
+ valueGt: parseNum(args['valueGt']),
173
+ valueLt: parseNum(args['valueLt']),
174
+ topN: parseNum(args['topN']),
175
+ returnFramesOnly: args['returnFramesOnly'] === true,
176
+ };
177
+ const cleaned = { ...args };
178
+ delete cleaned['valueGt'];
179
+ delete cleaned['valueLt'];
180
+ delete cleaned['topN'];
181
+ delete cleaned['returnFramesOnly'];
182
+ return { filter, cleaned };
183
+ }
184
+ export function hasCurveFilter(filter) {
185
+ return (filter.valueGt !== undefined ||
186
+ filter.valueLt !== undefined ||
187
+ filter.topN !== undefined ||
188
+ filter.returnFramesOnly === true);
189
+ }
190
+ /**
191
+ * 对 y_axis 曲线做客户端过滤,解决「慢帧列表必须全量进上下文」的问题。
192
+ */
193
+ export function filterCurveData(data, filter) {
194
+ if (!hasCurveFilter(filter) || !isObj(data) || !isObj(data['y_axis']))
195
+ return data;
196
+ const yAxis = data['y_axis'];
197
+ const sharedX = isObj(data['x_axis']) && Array.isArray(data['x_axis']['data']) ? data['x_axis']['data'] : null;
198
+ const nextY = {};
199
+ const filterMeta = {};
200
+ for (const [name, curve] of Object.entries(yAxis)) {
201
+ if (!isObj(curve) || !Array.isArray(curve['data'])) {
202
+ nextY[name] = curve;
203
+ continue;
204
+ }
205
+ const values = curve['data'];
206
+ const ownX = isObj(curve['x_axis']) && Array.isArray(curve['x_axis']['data'])
207
+ ? curve['x_axis']['data']
208
+ : sharedX;
209
+ const pairs = [];
210
+ for (let i = 0; i < values.length; i++) {
211
+ const v = values[i];
212
+ if (typeof v !== 'number' || !Number.isFinite(v))
213
+ continue;
214
+ if (filter.valueGt !== undefined && !(v > filter.valueGt))
215
+ continue;
216
+ if (filter.valueLt !== undefined && !(v < filter.valueLt))
217
+ continue;
218
+ pairs.push({ frame: ownX?.[i] ?? i + 1, value: v, idx: i });
219
+ }
220
+ let kept = pairs;
221
+ if (filter.topN !== undefined && filter.topN > 0 && kept.length > filter.topN) {
222
+ kept = [...kept].sort((a, b) => b.value - a.value).slice(0, filter.topN);
223
+ kept.sort((a, b) => Number(a.frame) - Number(b.frame));
224
+ }
225
+ filterMeta[name] = {
226
+ originalPoints: values.length,
227
+ keptPoints: kept.length,
228
+ valueGt: filter.valueGt ?? null,
229
+ valueLt: filter.valueLt ?? null,
230
+ topN: filter.topN ?? null,
231
+ };
232
+ if (filter.returnFramesOnly) {
233
+ nextY[name] = {
234
+ unit: { description: '帧', value: 'frame' },
235
+ frames: kept.map((p) => p.frame),
236
+ values: kept.map((p) => p.value),
237
+ };
238
+ }
239
+ else {
240
+ nextY[name] = {
241
+ ...curve,
242
+ data: kept.map((p) => p.value),
243
+ x_axis: {
244
+ unit: isObj(curve['x_axis']) ? curve['x_axis']['unit'] : { description: '帧', value: 'frame' },
245
+ data: kept.map((p) => p.frame),
246
+ },
247
+ };
248
+ }
249
+ }
250
+ return {
251
+ ...data,
252
+ y_axis: nextY,
253
+ _curveFilter: filterMeta,
254
+ _curveFilterHint: '已按 valueGt/valueLt/topN/returnFramesOnly 在 MCP 侧过滤曲线点;完整曲线去掉这些参数即可。',
255
+ };
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
+ }
267
+ export function isIndicatorStatisticDashboard(opPath) {
268
+ return opPath.includes('/indicator/statistic/dashboard') || opPath.includes('/custom/dashboard');
269
+ }
270
+ export function isIndicatorCurveDashboard(opPath) {
271
+ return opPath.includes('/indicator/curve/dashboard') || opPath.includes('/gpu/curve');
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
+ }
399
+ /** 统计面板成功返回后的统一标注管线。 */
400
+ export function annotateStatisticDashboard(data, accepted, unknown) {
401
+ let out = annotateSceneUnits(data);
402
+ out = annotateDuplicateFlatAliases(out);
403
+ out = annotateParamFieldMap(out);
404
+ if (!isObj(out))
405
+ return out;
406
+ const pssCard = extractPssPeakCard(out);
407
+ return {
408
+ ...out,
409
+ ...(pssCard ?? {}),
410
+ _acceptedDashboards: accepted,
411
+ ...(unknown.length ? { _unknownDashboards: unknown } : {}),
412
+ };
413
+ }
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
  }