u1s1-cli 1.4.3 → 1.6.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/dist/agent-setup.js +27 -6
- package/dist/brand.js +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/deploy.d.ts +5 -0
- package/dist/deploy.js +87 -26
- package/dist/embed.d.ts +1 -0
- package/dist/embed.js +1 -0
- package/dist/error-humanize.d.ts +7 -1
- package/dist/error-humanize.js +34 -2
- package/dist/feedback.d.ts +66 -0
- package/dist/feedback.js +200 -0
- package/dist/index.js +111 -4
- package/dist/nudges.d.ts +21 -0
- package/dist/nudges.js +40 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +17 -0
- package/dist/request-trace.d.ts +12 -0
- package/dist/request-trace.js +45 -0
- package/dist/telemetry.d.ts +56 -0
- package/dist/telemetry.js +146 -0
- package/dist/update.js +2 -2
- package/dist/usage.d.ts +3 -0
- package/dist/usage.js +11 -1
- package/package.json +1 -1
- package/scripts/patch-pi.js +111 -1
package/dist/agent-setup.js
CHANGED
|
@@ -221,8 +221,10 @@ export function writeWebToolsExtension(cfg, features) {
|
|
|
221
221
|
` }\n` +
|
|
222
222
|
` });\n` +
|
|
223
223
|
` pi.registerTool(tools.createSubagentTool(getParentModel));\n` +
|
|
224
|
-
`
|
|
225
|
-
`
|
|
224
|
+
` try {\n` +
|
|
225
|
+
` const workflow = await import(${JSON.stringify(new URL("./workflow/tool.js", import.meta.url).href)});\n` +
|
|
226
|
+
` pi.registerTool(workflow.createRunWorkflowTool(getParentModel));\n` +
|
|
227
|
+
` } catch {}\n` +
|
|
226
228
|
` }\n`;
|
|
227
229
|
// 密钥剥离守卫也从这里装:Desktop App 的 pi 跑在 pi-web-ui 子进程里,没有
|
|
228
230
|
// 启动器代码,只有扩展能替它把 U1S1_API_KEY / U1S1_EP_KEY_* 挡在子进程之外
|
|
@@ -232,7 +234,17 @@ export function writeWebToolsExtension(cfg, features) {
|
|
|
232
234
|
` (await import(${JSON.stringify(secretEnvUrl)})).installChildEnvGuard();\n` +
|
|
233
235
|
` if (process.env.U1S1_TOOLS_VIA_EXTENSION !== "1") return;\n` +
|
|
234
236
|
` const baseUrl = process.env.U1S1_SIGNING_PROXY_URL || ${JSON.stringify(cfg.baseUrl)};\n` +
|
|
235
|
-
|
|
237
|
+
// 升级半途 / 权限问题导致 CLI dist 不可读时,宁可这一会话没有联网工具,
|
|
238
|
+
// 也不能让扩展加载失败刷英文堆栈(审计 B4);只在 UI 里提示一句中文
|
|
239
|
+
` let tools;\n` +
|
|
240
|
+
` try {\n` +
|
|
241
|
+
` tools = await import(${JSON.stringify(toolsUrl)});\n` +
|
|
242
|
+
` } catch {\n` +
|
|
243
|
+
` pi.on("session_start", (_event, ctx) => {\n` +
|
|
244
|
+
` try { if (ctx.hasUI) ctx.ui.notify("联网工具本次未能加载(程序文件可能正在更新),重启 u1s1 即可恢复", "warning"); } catch {}\n` +
|
|
245
|
+
` });\n` +
|
|
246
|
+
` return;\n` +
|
|
247
|
+
` }\n` +
|
|
236
248
|
searchLine +
|
|
237
249
|
` pi.registerTool(tools.createFetchTool({ baseUrl, apiKey: process.env.U1S1_API_KEY, renderFallback: ${features.webFetchRender} }));\n` +
|
|
238
250
|
imageLine +
|
|
@@ -251,17 +263,21 @@ export function writeErrorHumanizeExtension() {
|
|
|
251
263
|
const dir = join(agentDir, "extensions");
|
|
252
264
|
mkdirSync(dir, { recursive: true });
|
|
253
265
|
const url = new URL("./error-humanize.js", import.meta.url).href;
|
|
266
|
+
const traceUrl = new URL("./request-trace.js", import.meta.url).href;
|
|
254
267
|
writeFileSync(join(dir, "u1s1-error-humanize.js"), `// 由 u1s1 每次启动自动生成,请勿手改\n` +
|
|
255
268
|
`export default async function (pi) {\n` +
|
|
256
|
-
` let humanize;\n` +
|
|
269
|
+
` let humanize, extractRequestId, rememberRequestId;\n` +
|
|
257
270
|
` try {\n` +
|
|
258
|
-
` humanize =
|
|
271
|
+
` ({ humanizeModelError: humanize, extractRequestId } = await import(${JSON.stringify(url)}));\n` +
|
|
272
|
+
` ({ rememberRequestId } = await import(${JSON.stringify(traceUrl)}));\n` +
|
|
259
273
|
` } catch {\n` +
|
|
260
274
|
` return;\n` +
|
|
261
275
|
` }\n` +
|
|
262
276
|
` pi.on("message_end", (event) => {\n` +
|
|
263
277
|
` const msg = event.message;\n` +
|
|
264
278
|
` if (msg.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;\n` +
|
|
279
|
+
` // 记下最近一次报错的请求编号,u1s1 feedback / /feedback 建工单时附上\n` +
|
|
280
|
+
` rememberRequestId(extractRequestId(msg.errorMessage));\n` +
|
|
265
281
|
` const friendly = humanize(msg.errorMessage);\n` +
|
|
266
282
|
` if (!friendly) return;\n` +
|
|
267
283
|
` return { message: { ...msg, errorMessage: friendly } };\n` +
|
|
@@ -287,7 +303,12 @@ export function writeAnnouncementsExtension() {
|
|
|
287
303
|
` } catch {\n` +
|
|
288
304
|
` return;\n` +
|
|
289
305
|
` }\n` +
|
|
290
|
-
`
|
|
306
|
+
` let Text;\n` +
|
|
307
|
+
` try {\n` +
|
|
308
|
+
` Text = (await import("@earendil-works/pi-tui")).Text;\n` +
|
|
309
|
+
` } catch {\n` +
|
|
310
|
+
` return;\n` +
|
|
311
|
+
` }\n` +
|
|
291
312
|
` pi.registerEntryRenderer("u1s1-announcement", (entry, _opts, theme) => {\n` +
|
|
292
313
|
` const d = entry.data ?? {};\n` +
|
|
293
314
|
` let text = theme.fg("text", theme.bold("📢 " + String(d.text ?? "")));\n` +
|
package/dist/brand.js
CHANGED
|
@@ -54,6 +54,7 @@ function starterLines(theme) {
|
|
|
54
54
|
` ${theme.fg("text", "「做一个自我介绍网页,做完帮我发布出去」")}`,
|
|
55
55
|
` ${theme.fg("text", "「做一个给朋友的生日祝福页面,要有点小动画」")}`,
|
|
56
56
|
` ${theme.fg("text", "「写一个把文件夹里照片按日期重命名的小工具」")}`,
|
|
57
|
+
` ${theme.fg("muted", "网页做好后,运行 u1s1 deploy --public 一键上线,拿到可分享的链接发给朋友")}`,
|
|
57
58
|
];
|
|
58
59
|
}
|
|
59
60
|
/**
|
package/dist/config.d.ts
CHANGED
|
@@ -150,6 +150,8 @@ export declare const agentSettingsFile: string;
|
|
|
150
150
|
export interface AgentSettings {
|
|
151
151
|
showStartupBanner?: boolean;
|
|
152
152
|
autoUpdate?: boolean;
|
|
153
|
+
/** false 时关闭客户端事件上报(见 telemetry.ts;环境变量 U1S1_TELEMETRY=0 同效) */
|
|
154
|
+
telemetry?: boolean;
|
|
153
155
|
[key: string]: unknown;
|
|
154
156
|
}
|
|
155
157
|
/** Read u1s1 agent settings (settings.json). Returns empty object if missing or invalid. */
|
package/dist/deploy.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { type CliConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* `u1s1 deploy remove` 的站点参数可以是 slug、完整网址或域名:
|
|
4
|
+
* `my-site` / `https://my-site.u1abc123.u1s1.app/` / `my-site.u1abc123.u1s1.app` 都指向 my-site。
|
|
5
|
+
*/
|
|
6
|
+
export declare function siteReferenceFromInput(input: string): string;
|
|
2
7
|
interface SiteFile {
|
|
3
8
|
path: string;
|
|
4
9
|
abs: string;
|
package/dist/deploy.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { createInterface } from "node:readline/promises";
|
|
4
3
|
import { VERSION, u1s1Dir } from "./config.js";
|
|
5
4
|
import { authorizedFetch } from "./device-auth.js";
|
|
6
5
|
import { readJsonResponseCapped } from "./api.js";
|
|
6
|
+
import { markNudge } from "./nudges.js";
|
|
7
|
+
import { askLine } from "./prompt.js";
|
|
8
|
+
import { sendTelemetryEvent } from "./telemetry.js";
|
|
7
9
|
/**
|
|
8
10
|
* u1s1 deploy publishes a static site to <project>.<account-code>.u1s1.app.
|
|
9
11
|
* Detect the site root, ask for a project slug, and remember it in ~/.u1s1/deploys.json.
|
|
@@ -36,6 +38,30 @@ function rememberSite(dir, site) {
|
|
|
36
38
|
// ignore
|
|
37
39
|
}
|
|
38
40
|
}
|
|
41
|
+
/** 站点删掉后把 deploys.json 里指向它的目录记录一并清掉,下次 deploy 重新问名字。 */
|
|
42
|
+
function forgetSite(site) {
|
|
43
|
+
try {
|
|
44
|
+
const all = readDeploys();
|
|
45
|
+
const remaining = Object.fromEntries(Object.entries(all).filter(([, value]) => value !== site));
|
|
46
|
+
if (Object.keys(remaining).length !== Object.keys(all).length) {
|
|
47
|
+
writeFileSync(deploysFile, JSON.stringify(remaining, null, 2) + "\n");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `u1s1 deploy remove` 的站点参数可以是 slug、完整网址或域名:
|
|
56
|
+
* `my-site` / `https://my-site.u1abc123.u1s1.app/` / `my-site.u1abc123.u1s1.app` 都指向 my-site。
|
|
57
|
+
*/
|
|
58
|
+
export function siteReferenceFromInput(input) {
|
|
59
|
+
let value = input.trim().toLowerCase();
|
|
60
|
+
value = value.replace(/^[a-z]+:\/\//, "");
|
|
61
|
+
value = value.replace(/[/?#].*$/, "");
|
|
62
|
+
const firstLabel = value.split(".")[0] ?? "";
|
|
63
|
+
return firstLabel;
|
|
64
|
+
}
|
|
39
65
|
/** 找要部署的目录:显式参数 > 含 index.html 的构建产物目录 > 当前目录本身。 */
|
|
40
66
|
function resolveSiteDir(explicit) {
|
|
41
67
|
if (explicit) {
|
|
@@ -211,33 +237,18 @@ async function uploadAll(cfg, start, files) {
|
|
|
211
237
|
}
|
|
212
238
|
}
|
|
213
239
|
async function promptSiteName(def) {
|
|
214
|
-
|
|
215
|
-
return def;
|
|
216
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
217
|
-
try {
|
|
218
|
-
const answer = (await rl.question(` 项目名(回车用 ${def},只需在你的账号内唯一):`)).trim().toLowerCase();
|
|
219
|
-
return answer || def;
|
|
220
|
-
}
|
|
221
|
-
finally {
|
|
222
|
-
rl.close();
|
|
223
|
-
}
|
|
240
|
+
return (await askLine(` 项目名(回车用 ${def},只需在你的账号内唯一):`, def)).toLowerCase();
|
|
224
241
|
}
|
|
225
242
|
async function promptVisibility() {
|
|
226
243
|
if (!process.stdin.isTTY)
|
|
227
244
|
return "private";
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return "public";
|
|
236
|
-
console.log(" 请输入 1 或 2");
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
finally {
|
|
240
|
-
rl.close();
|
|
245
|
+
while (true) {
|
|
246
|
+
const answer = (await askLine(" 发布方式 [1] 公开(进入作品社区) [2] 私密(仅自己登录后可看,默认):")).toLowerCase();
|
|
247
|
+
if (!answer || answer === "2" || answer === "private" || answer === "私密")
|
|
248
|
+
return "private";
|
|
249
|
+
if (answer === "1" || answer === "public" || answer === "公开")
|
|
250
|
+
return "public";
|
|
251
|
+
console.log(" 请输入 1 或 2");
|
|
241
252
|
}
|
|
242
253
|
}
|
|
243
254
|
async function listDeployments(cfg) {
|
|
@@ -255,6 +266,44 @@ async function listDeployments(cfg) {
|
|
|
255
266
|
}
|
|
256
267
|
console.log("");
|
|
257
268
|
}
|
|
269
|
+
async function confirmRemoval(url) {
|
|
270
|
+
const answer = (await askLine(` 确定删除 ${url} 吗?网址会立即失效,站点文件会被清除,无法恢复。输入 y 确认:`)).toLowerCase();
|
|
271
|
+
return answer === "y" || answer === "yes";
|
|
272
|
+
}
|
|
273
|
+
async function removeDeployment(cfg, args) {
|
|
274
|
+
const yes = args.some((arg) => arg === "--yes" || arg === "-y");
|
|
275
|
+
const positional = args.filter((arg) => !arg.startsWith("-"));
|
|
276
|
+
if (positional.length !== 1) {
|
|
277
|
+
throw new Error("用法:u1s1 deploy remove <站点名或网址> [--yes]");
|
|
278
|
+
}
|
|
279
|
+
const reference = siteReferenceFromInput(positional[0]);
|
|
280
|
+
if (!reference)
|
|
281
|
+
throw new Error("站点名不能为空");
|
|
282
|
+
// 先按列表核对,拿到完整网址给确认提示;名字打错在这里就能提前发现
|
|
283
|
+
const { sites } = await api(cfg, { method: "GET", path: "/deploy/sites" });
|
|
284
|
+
const site = sites.find((s) => (s.slug || s.name) === reference || s.name === reference);
|
|
285
|
+
if (!site) {
|
|
286
|
+
throw new Error(`没有找到站点「${reference}」。运行 u1s1 deploy list 查看已有站点`);
|
|
287
|
+
}
|
|
288
|
+
if (!yes) {
|
|
289
|
+
if (!process.stdin.isTTY) {
|
|
290
|
+
throw new Error("非交互环境下删除站点需要加 --yes 明确确认");
|
|
291
|
+
}
|
|
292
|
+
if (!(await confirmRemoval(site.url))) {
|
|
293
|
+
console.log(" 已取消,站点保留。");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
await api(cfg, {
|
|
298
|
+
method: "DELETE",
|
|
299
|
+
path: `/deploy/sites/${encodeURIComponent(site.name)}`,
|
|
300
|
+
});
|
|
301
|
+
forgetSite(site.slug || site.name);
|
|
302
|
+
console.log("");
|
|
303
|
+
console.log(` ✓ 已删除 ${site.url}`);
|
|
304
|
+
console.log(" 同一个名字可以再次 u1s1 deploy 重新发布。");
|
|
305
|
+
console.log("");
|
|
306
|
+
}
|
|
258
307
|
async function selectDeployment(args) {
|
|
259
308
|
const parsed = parseDeployArgs(args);
|
|
260
309
|
let { name, dirArg, visibility } = parsed;
|
|
@@ -349,11 +398,13 @@ export function printDeployHelp() {
|
|
|
349
398
|
console.log(" 不带参数时自动找构建产物目录(dist/build/out 等),否则用当前目录。");
|
|
350
399
|
console.log(" 首次发布会问项目名和公开/私密,之后记住;再跑一次即为更新。");
|
|
351
400
|
console.log("");
|
|
352
|
-
console.log(" u1s1 deploy list
|
|
401
|
+
console.log(" u1s1 deploy list 查看已发布的站点");
|
|
402
|
+
console.log(" u1s1 deploy remove <站点名或网址> 删除站点(会二次确认,--yes 跳过)");
|
|
353
403
|
console.log("");
|
|
354
404
|
}
|
|
355
405
|
export async function deployCommand(cfg, args) {
|
|
356
|
-
// 参数:[dir] [--name xxx] [--public|--private];u1s1 deploy list
|
|
406
|
+
// 参数:[dir] [--name xxx] [--public|--private];u1s1 deploy list 列出已有站点;
|
|
407
|
+
// u1s1 deploy remove <站点> 删除站点(rm/delete 同义)
|
|
357
408
|
if (args.includes("--help") || args.includes("-h")) {
|
|
358
409
|
printDeployHelp();
|
|
359
410
|
return;
|
|
@@ -362,6 +413,10 @@ export async function deployCommand(cfg, args) {
|
|
|
362
413
|
await listDeployments(cfg);
|
|
363
414
|
return;
|
|
364
415
|
}
|
|
416
|
+
if (args[0] === "remove" || args[0] === "rm" || args[0] === "delete") {
|
|
417
|
+
await removeDeployment(cfg, args.slice(1));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
365
420
|
const selection = await selectDeployment(args);
|
|
366
421
|
const { name, start } = await startDeployment(cfg, selection.name);
|
|
367
422
|
validateDeploymentLimits(start, selection.files, selection.totalBytes);
|
|
@@ -376,5 +431,11 @@ export async function deployCommand(cfg, args) {
|
|
|
376
431
|
},
|
|
377
432
|
});
|
|
378
433
|
rememberSite(selection.dir, start.slug || name || start.site);
|
|
434
|
+
// 部署过一次之后,会话里不再推「u1s1 deploy 一键上线」
|
|
435
|
+
markNudge("deploy_done");
|
|
379
436
|
printDeploymentResult(result, start);
|
|
437
|
+
await sendTelemetryEvent(cfg, "deploy_done", {
|
|
438
|
+
public: result.visibility === "public",
|
|
439
|
+
file_count: result.file_count,
|
|
440
|
+
});
|
|
380
441
|
}
|
package/dist/embed.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export { apiOrigin, pollDeviceLogin, startDeviceLogin, type DeviceStart } from "
|
|
|
9
9
|
export { prepareWebEnv, refreshWebModels } from "./web.js";
|
|
10
10
|
export { applyWebUiBranding } from "./webui-brand.js";
|
|
11
11
|
export { DASHBOARD_URL } from "./brand.js";
|
|
12
|
+
export { hasNudge, markNudge, readNudges, type NudgeKey } from "./nudges.js";
|
package/dist/embed.js
CHANGED
|
@@ -9,3 +9,4 @@ export { apiOrigin, pollDeviceLogin, startDeviceLogin } from "./login.js";
|
|
|
9
9
|
export { prepareWebEnv, refreshWebModels } from "./web.js";
|
|
10
10
|
export { applyWebUiBranding } from "./webui-brand.js";
|
|
11
11
|
export { DASHBOARD_URL } from "./brand.js";
|
|
12
|
+
export { hasNudge, markNudge, readNudges } from "./nudges.js";
|
package/dist/error-humanize.d.ts
CHANGED
|
@@ -11,5 +11,11 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
|
|
13
13
|
* 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
|
|
14
|
+
*
|
|
15
|
+
* 额度用尽(reason=exhausted)时网关还带 error.resets_at(免费池刷新时刻,ISO),
|
|
16
|
+
* 这里换算成「距刷新还有 N 小时 M 分」接在正文后:比「北京时间 0 点」更省用户脑子。
|
|
14
17
|
*/
|
|
15
|
-
export declare function
|
|
18
|
+
export declare function quotaResetHint(resetsAt: unknown, now?: number): string;
|
|
19
|
+
/** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */
|
|
20
|
+
export declare function extractRequestId(raw: string): string | undefined;
|
|
21
|
+
export declare function humanizeModelError(raw: string, now?: number): string | undefined;
|
package/dist/error-humanize.js
CHANGED
|
@@ -11,8 +11,25 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
|
|
13
13
|
* 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
|
|
14
|
+
*
|
|
15
|
+
* 额度用尽(reason=exhausted)时网关还带 error.resets_at(免费池刷新时刻,ISO),
|
|
16
|
+
* 这里换算成「距刷新还有 N 小时 M 分」接在正文后:比「北京时间 0 点」更省用户脑子。
|
|
14
17
|
*/
|
|
15
|
-
export function
|
|
18
|
+
export function quotaResetHint(resetsAt, now = Date.now()) {
|
|
19
|
+
if (typeof resetsAt !== "string")
|
|
20
|
+
return "";
|
|
21
|
+
const at = Date.parse(resetsAt);
|
|
22
|
+
if (!Number.isFinite(at))
|
|
23
|
+
return "";
|
|
24
|
+
const remainingMinutes = Math.ceil((at - now) / 60_000);
|
|
25
|
+
if (remainingMinutes <= 0 || remainingMinutes > 48 * 60)
|
|
26
|
+
return "";
|
|
27
|
+
const hours = Math.floor(remainingMinutes / 60);
|
|
28
|
+
const minutes = remainingMinutes % 60;
|
|
29
|
+
const span = hours > 0 ? `${hours} 小时${minutes > 0 ? ` ${minutes} 分` : ""}` : `${minutes} 分`;
|
|
30
|
+
return `距免费额度刷新还有 ${span}`;
|
|
31
|
+
}
|
|
32
|
+
function parseErrorPayload(raw) {
|
|
16
33
|
const jsonStart = raw.indexOf("{");
|
|
17
34
|
const jsonEnd = raw.lastIndexOf("}");
|
|
18
35
|
if (jsonStart === -1 || jsonEnd <= jsonStart)
|
|
@@ -30,6 +47,19 @@ export function humanizeModelError(raw) {
|
|
|
30
47
|
const err = root["error"] !== null && typeof root["error"] === "object"
|
|
31
48
|
? root["error"]
|
|
32
49
|
: root;
|
|
50
|
+
return { err, jsonStart };
|
|
51
|
+
}
|
|
52
|
+
/** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */
|
|
53
|
+
export function extractRequestId(raw) {
|
|
54
|
+
const payload = parseErrorPayload(raw);
|
|
55
|
+
const id = payload?.err["request_id"];
|
|
56
|
+
return typeof id === "string" && id.length > 0 && id.length <= 128 ? id : undefined;
|
|
57
|
+
}
|
|
58
|
+
export function humanizeModelError(raw, now = Date.now()) {
|
|
59
|
+
const payload = parseErrorPayload(raw);
|
|
60
|
+
if (!payload)
|
|
61
|
+
return undefined;
|
|
62
|
+
const { err, jsonStart } = payload;
|
|
33
63
|
const message = typeof err["message"] === "string" ? err["message"].trim() : "";
|
|
34
64
|
if (!message)
|
|
35
65
|
return undefined;
|
|
@@ -42,6 +72,8 @@ export function humanizeModelError(raw) {
|
|
|
42
72
|
type === "insufficient_quota" || code === "quota_exceeded" ? "insufficient_quota" : code,
|
|
43
73
|
requestId ? `请求编号 ${requestId}` : "",
|
|
44
74
|
].filter(Boolean);
|
|
45
|
-
const
|
|
75
|
+
const resetHint = err["reason"] === "exhausted" ? quotaResetHint(err["resets_at"], now) : "";
|
|
76
|
+
const body = resetHint ? `${message}〔${resetHint}〕` : message;
|
|
77
|
+
const friendly = tags.length ? `${body} (${tags.join(" · ")})` : body;
|
|
46
78
|
return friendly === raw ? undefined : friendly;
|
|
47
79
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type CliConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* `u1s1 feedback [text]` / 会话内 `/feedback`:一条命令直接建工单,并附上
|
|
4
|
+
* 版本、平台、最近一次请求编号等诊断快照(运营清单 E4),不用再手写邮件。
|
|
5
|
+
* 走 POST /v1/support/tickets(设备签名),201 返回 {id, url}。
|
|
6
|
+
*/
|
|
7
|
+
export declare const FEEDBACK_CATEGORIES: readonly ["question", "bug", "billing", "suggestion", "other"];
|
|
8
|
+
export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number];
|
|
9
|
+
export declare const FEEDBACK_SUBJECT_MAX = 50;
|
|
10
|
+
export declare const FEEDBACK_MESSAGE_MAX = 4000;
|
|
11
|
+
export declare const SUPPORT_URL = "https://u1s1.io/dashboard#sec-support";
|
|
12
|
+
export interface FeedbackArgs {
|
|
13
|
+
category: FeedbackCategory;
|
|
14
|
+
text: string;
|
|
15
|
+
help?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** `--bug` 等旗标可放在任意位置,其余 token 拼成正文。 */
|
|
18
|
+
export declare function parseFeedbackArgs(args: string[]): FeedbackArgs;
|
|
19
|
+
export interface FeedbackDiagnostics {
|
|
20
|
+
client_version: string;
|
|
21
|
+
platform: string;
|
|
22
|
+
node_version: string;
|
|
23
|
+
request_id?: string;
|
|
24
|
+
model?: string;
|
|
25
|
+
/** terminal(u1s1 feedback)/ session(会话内 /feedback) */
|
|
26
|
+
surface?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FeedbackRequest {
|
|
29
|
+
category: FeedbackCategory;
|
|
30
|
+
subject: string;
|
|
31
|
+
message: string;
|
|
32
|
+
diagnostics: FeedbackDiagnostics;
|
|
33
|
+
}
|
|
34
|
+
/** 主题 = 正文首行前 50 字(空白折叠);正文原样保留,超长截断。 */
|
|
35
|
+
export declare function buildFeedbackRequest(input: {
|
|
36
|
+
text: string;
|
|
37
|
+
category?: FeedbackCategory;
|
|
38
|
+
diagnostics: FeedbackDiagnostics;
|
|
39
|
+
}): FeedbackRequest;
|
|
40
|
+
/** 诊断快照:版本/平台/Node/最近请求编号/当前模型。model 由调用方决定来源。 */
|
|
41
|
+
export declare function collectDiagnostics(extra?: {
|
|
42
|
+
model?: string;
|
|
43
|
+
surface?: string;
|
|
44
|
+
}): FeedbackDiagnostics;
|
|
45
|
+
export type FeedbackOutcome = {
|
|
46
|
+
ok: true;
|
|
47
|
+
id: string;
|
|
48
|
+
url: string;
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
status: number;
|
|
52
|
+
message: string;
|
|
53
|
+
retryAfterSeconds?: number;
|
|
54
|
+
};
|
|
55
|
+
/** 提交工单;网络错误直接抛,HTTP 错误(含 429)转成结构化结果由调用方措辞。 */
|
|
56
|
+
export declare function submitFeedback(cfg: CliConfig, body: FeedbackRequest): Promise<FeedbackOutcome>;
|
|
57
|
+
/** 结果措辞,终端与会话内共用(每项一行)。 */
|
|
58
|
+
export declare function feedbackOutcomeLines(outcome: FeedbackOutcome): string[];
|
|
59
|
+
export declare function printFeedbackHelp(): void;
|
|
60
|
+
/** 终端命令入口。 */
|
|
61
|
+
export declare function feedbackCommand(args: string[]): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* 会话内 /feedback:参数字符串按空格切开复用同一套解析;正文为空时由调用方
|
|
64
|
+
* 先用 ctx.ui.input 问过再传进来。返回要展示的行。
|
|
65
|
+
*/
|
|
66
|
+
export declare function feedbackInSession(rawArgs: string, model?: string): Promise<string[]>;
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { readJsonResponseCapped } from "./api.js";
|
|
2
|
+
import { loadConfig, PROVIDER_ID, resolvePreferredModel, VERSION } from "./config.js";
|
|
3
|
+
import { authorizedFetch, hasDeviceCredential } from "./device-auth.js";
|
|
4
|
+
import { askLine } from "./prompt.js";
|
|
5
|
+
import { lastRequestId } from "./request-trace.js";
|
|
6
|
+
/**
|
|
7
|
+
* `u1s1 feedback [text]` / 会话内 `/feedback`:一条命令直接建工单,并附上
|
|
8
|
+
* 版本、平台、最近一次请求编号等诊断快照(运营清单 E4),不用再手写邮件。
|
|
9
|
+
* 走 POST /v1/support/tickets(设备签名),201 返回 {id, url}。
|
|
10
|
+
*/
|
|
11
|
+
export const FEEDBACK_CATEGORIES = ["question", "bug", "billing", "suggestion", "other"];
|
|
12
|
+
const CATEGORY_LABELS = {
|
|
13
|
+
question: "提问",
|
|
14
|
+
bug: "故障",
|
|
15
|
+
billing: "计费",
|
|
16
|
+
suggestion: "建议",
|
|
17
|
+
other: "其他",
|
|
18
|
+
};
|
|
19
|
+
export const FEEDBACK_SUBJECT_MAX = 50;
|
|
20
|
+
export const FEEDBACK_MESSAGE_MAX = 4_000;
|
|
21
|
+
export const SUPPORT_URL = "https://u1s1.io/dashboard#sec-support";
|
|
22
|
+
const MAX_TICKET_RESPONSE_BYTES = 64 * 1024;
|
|
23
|
+
/** `--bug` 等旗标可放在任意位置,其余 token 拼成正文。 */
|
|
24
|
+
export function parseFeedbackArgs(args) {
|
|
25
|
+
let category = "other";
|
|
26
|
+
const words = [];
|
|
27
|
+
for (const arg of args) {
|
|
28
|
+
if (arg === "--help" || arg === "-h")
|
|
29
|
+
return { category, text: "", help: true };
|
|
30
|
+
if (arg.startsWith("--") && FEEDBACK_CATEGORIES.includes(arg.slice(2))) {
|
|
31
|
+
category = arg.slice(2);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg.startsWith("--"))
|
|
35
|
+
throw new Error(`不认识的 feedback 参数:${arg}(可用 --bug --question --suggestion --billing)`);
|
|
36
|
+
words.push(arg);
|
|
37
|
+
}
|
|
38
|
+
return { category, text: words.join(" ").trim() };
|
|
39
|
+
}
|
|
40
|
+
/** 主题 = 正文首行前 50 字(空白折叠);正文原样保留,超长截断。 */
|
|
41
|
+
export function buildFeedbackRequest(input) {
|
|
42
|
+
const message = input.text.replace(/\r\n?/g, "\n").trim();
|
|
43
|
+
if (!message)
|
|
44
|
+
throw new Error("反馈内容不能为空");
|
|
45
|
+
const firstLine = message.split("\n").find((l) => l.trim())?.replace(/\s+/g, " ").trim() ?? message;
|
|
46
|
+
const subject = firstLine.length > FEEDBACK_SUBJECT_MAX
|
|
47
|
+
? `${firstLine.slice(0, FEEDBACK_SUBJECT_MAX - 1)}…`
|
|
48
|
+
: firstLine;
|
|
49
|
+
const diagnostics = { ...input.diagnostics };
|
|
50
|
+
// 空值不发,免得服务端存一堆 undefined/空串
|
|
51
|
+
for (const key of Object.keys(diagnostics)) {
|
|
52
|
+
if (!diagnostics[key])
|
|
53
|
+
delete diagnostics[key];
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
category: input.category ?? "other",
|
|
57
|
+
subject,
|
|
58
|
+
message: message.length > FEEDBACK_MESSAGE_MAX ? message.slice(0, FEEDBACK_MESSAGE_MAX) : message,
|
|
59
|
+
diagnostics,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** 诊断快照:版本/平台/Node/最近请求编号/当前模型。model 由调用方决定来源。 */
|
|
63
|
+
export function collectDiagnostics(extra = {}) {
|
|
64
|
+
return {
|
|
65
|
+
client_version: VERSION,
|
|
66
|
+
platform: `${process.platform}-${process.arch}`,
|
|
67
|
+
node_version: process.version,
|
|
68
|
+
request_id: lastRequestId(),
|
|
69
|
+
model: extra.model,
|
|
70
|
+
surface: extra.surface,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function preferredModelLabel(cfg) {
|
|
74
|
+
const ref = resolvePreferredModel(cfg);
|
|
75
|
+
return ref.provider === PROVIDER_ID ? ref.id : `${ref.provider}/${ref.id}`;
|
|
76
|
+
}
|
|
77
|
+
/** 提交工单;网络错误直接抛,HTTP 错误(含 429)转成结构化结果由调用方措辞。 */
|
|
78
|
+
export async function submitFeedback(cfg, body) {
|
|
79
|
+
let resp;
|
|
80
|
+
try {
|
|
81
|
+
resp = await authorizedFetch(cfg, `${cfg.baseUrl}/support/tickets`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "x-u1s1-version": VERSION, "content-type": "application/json" },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal: AbortSignal.timeout(15_000),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
|
|
90
|
+
}
|
|
91
|
+
const value = await readJsonResponseCapped(resp, MAX_TICKET_RESPONSE_BYTES).catch(() => null);
|
|
92
|
+
const data = value && typeof value === "object" && !Array.isArray(value)
|
|
93
|
+
? value
|
|
94
|
+
: null;
|
|
95
|
+
if (resp.status === 201 || resp.status === 200) {
|
|
96
|
+
const id = typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : "";
|
|
97
|
+
const url = typeof data?.url === "string" && data.url.startsWith("https://") ? data.url : SUPPORT_URL;
|
|
98
|
+
if (!id)
|
|
99
|
+
return { ok: false, status: resp.status, message: "服务端工单响应格式不正确" };
|
|
100
|
+
return { ok: true, id, url };
|
|
101
|
+
}
|
|
102
|
+
const header = Number(resp.headers.get("retry-after"));
|
|
103
|
+
const fromBody = Number(data?.error?.retry_after);
|
|
104
|
+
const retryAfterSeconds = Number.isFinite(header) && header > 0
|
|
105
|
+
? header
|
|
106
|
+
: Number.isFinite(fromBody) && fromBody > 0 ? fromBody : undefined;
|
|
107
|
+
const message = typeof data?.error?.message === "string"
|
|
108
|
+
? data.error.message.slice(0, 500)
|
|
109
|
+
: resp.status === 401
|
|
110
|
+
? "登录已失效,请重新运行 u1s1 login"
|
|
111
|
+
: `服务端返回 ${resp.status},稍后再试`;
|
|
112
|
+
return { ok: false, status: resp.status, message, retryAfterSeconds };
|
|
113
|
+
}
|
|
114
|
+
/** 结果措辞,终端与会话内共用(每项一行)。 */
|
|
115
|
+
export function feedbackOutcomeLines(outcome) {
|
|
116
|
+
if (outcome.ok) {
|
|
117
|
+
return [
|
|
118
|
+
`✅ 反馈已提交,工单编号 ${outcome.id}。`,
|
|
119
|
+
`进度与回复在这里看:${outcome.url}`,
|
|
120
|
+
"我们通常会在 24 小时内回复到你的注册邮箱。",
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
if (outcome.status === 429) {
|
|
124
|
+
const wait = outcome.retryAfterSeconds
|
|
125
|
+
? `请等 ${Math.ceil(outcome.retryAfterSeconds / 60) || 1} 分钟后再试`
|
|
126
|
+
: "请稍等几分钟再试";
|
|
127
|
+
return [`提交太频繁了,${wait}。`, `等不及的话直接到后台留言:${SUPPORT_URL}`];
|
|
128
|
+
}
|
|
129
|
+
return [`提交失败:${outcome.message}`, `可以直接到后台留言:${SUPPORT_URL}`];
|
|
130
|
+
}
|
|
131
|
+
export function printFeedbackHelp() {
|
|
132
|
+
console.log("");
|
|
133
|
+
console.log(" u1s1 feedback [--bug|--question|--suggestion|--billing] [一句话描述]");
|
|
134
|
+
console.log("");
|
|
135
|
+
console.log(" 不带描述时会问你一行;分类不写默认「其他」。");
|
|
136
|
+
console.log(" 会自动附上版本、平台、最近一次报错的请求编号,方便我们定位。");
|
|
137
|
+
console.log(` 也可以在后台留言:${SUPPORT_URL}`);
|
|
138
|
+
console.log("");
|
|
139
|
+
}
|
|
140
|
+
/** 终端命令入口。 */
|
|
141
|
+
export async function feedbackCommand(args) {
|
|
142
|
+
const parsed = parseFeedbackArgs(args);
|
|
143
|
+
if (parsed.help) {
|
|
144
|
+
printFeedbackHelp();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const cfg = loadConfig();
|
|
148
|
+
if (!hasDeviceCredential(cfg)) {
|
|
149
|
+
console.error("还没登录,先跑 u1s1 login");
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
let text = parsed.text;
|
|
154
|
+
if (!text) {
|
|
155
|
+
text = await askLine(` 想反馈什么?(${CATEGORY_LABELS[parsed.category]},一行说清即可):`);
|
|
156
|
+
}
|
|
157
|
+
if (!text) {
|
|
158
|
+
console.error("反馈内容不能为空。用法:u1s1 feedback \"一句话描述\"");
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const body = buildFeedbackRequest({
|
|
163
|
+
text,
|
|
164
|
+
category: parsed.category,
|
|
165
|
+
diagnostics: collectDiagnostics({ model: preferredModelLabel(cfg), surface: "terminal" }),
|
|
166
|
+
});
|
|
167
|
+
const outcome = await submitFeedback(cfg, body);
|
|
168
|
+
console.log("");
|
|
169
|
+
for (const line of feedbackOutcomeLines(outcome))
|
|
170
|
+
console.log(` ${line}`);
|
|
171
|
+
console.log("");
|
|
172
|
+
if (!outcome.ok)
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 会话内 /feedback:参数字符串按空格切开复用同一套解析;正文为空时由调用方
|
|
177
|
+
* 先用 ctx.ui.input 问过再传进来。返回要展示的行。
|
|
178
|
+
*/
|
|
179
|
+
export async function feedbackInSession(rawArgs, model) {
|
|
180
|
+
const parsed = parseFeedbackArgs(rawArgs.split(/\s+/).filter(Boolean));
|
|
181
|
+
if (parsed.help) {
|
|
182
|
+
return ["/feedback [--bug|--question|--suggestion|--billing] 一句话描述", `或到后台留言:${SUPPORT_URL}`];
|
|
183
|
+
}
|
|
184
|
+
const cfg = loadConfig();
|
|
185
|
+
if (!hasDeviceCredential(cfg))
|
|
186
|
+
return ["还没登录,先在终端运行 u1s1 login"];
|
|
187
|
+
if (!parsed.text)
|
|
188
|
+
return ["反馈内容不能为空,例如:/feedback --bug 生成的网页打不开"];
|
|
189
|
+
const body = buildFeedbackRequest({
|
|
190
|
+
text: parsed.text,
|
|
191
|
+
category: parsed.category,
|
|
192
|
+
diagnostics: collectDiagnostics({ model, surface: "session" }),
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
return feedbackOutcomeLines(await submitFeedback(cfg, body));
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
return [e instanceof Error ? e.message : String(e), `可以直接到后台留言:${SUPPORT_URL}`];
|
|
199
|
+
}
|
|
200
|
+
}
|