u1s1-cli 1.4.3 → 1.5.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.
@@ -251,17 +251,21 @@ export function writeErrorHumanizeExtension() {
251
251
  const dir = join(agentDir, "extensions");
252
252
  mkdirSync(dir, { recursive: true });
253
253
  const url = new URL("./error-humanize.js", import.meta.url).href;
254
+ const traceUrl = new URL("./request-trace.js", import.meta.url).href;
254
255
  writeFileSync(join(dir, "u1s1-error-humanize.js"), `// 由 u1s1 每次启动自动生成,请勿手改\n` +
255
256
  `export default async function (pi) {\n` +
256
- ` let humanize;\n` +
257
+ ` let humanize, extractRequestId, rememberRequestId;\n` +
257
258
  ` try {\n` +
258
- ` humanize = (await import(${JSON.stringify(url)})).humanizeModelError;\n` +
259
+ ` ({ humanizeModelError: humanize, extractRequestId } = await import(${JSON.stringify(url)}));\n` +
260
+ ` ({ rememberRequestId } = await import(${JSON.stringify(traceUrl)}));\n` +
259
261
  ` } catch {\n` +
260
262
  ` return;\n` +
261
263
  ` }\n` +
262
264
  ` pi.on("message_end", (event) => {\n` +
263
265
  ` const msg = event.message;\n` +
264
266
  ` if (msg.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;\n` +
267
+ ` // 记下最近一次报错的请求编号,u1s1 feedback / /feedback 建工单时附上\n` +
268
+ ` rememberRequestId(extractRequestId(msg.errorMessage));\n` +
265
269
  ` const friendly = humanize(msg.errorMessage);\n` +
266
270
  ` if (!friendly) return;\n` +
267
271
  ` return { message: { ...msg, errorMessage: friendly } };\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.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.
@@ -211,33 +213,18 @@ async function uploadAll(cfg, start, files) {
211
213
  }
212
214
  }
213
215
  async function promptSiteName(def) {
214
- if (!process.stdin.isTTY)
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
- }
216
+ return (await askLine(` 项目名(回车用 ${def},只需在你的账号内唯一):`, def)).toLowerCase();
224
217
  }
225
218
  async function promptVisibility() {
226
219
  if (!process.stdin.isTTY)
227
220
  return "private";
228
- const rl = createInterface({ input: process.stdin, output: process.stdout });
229
- try {
230
- while (true) {
231
- const answer = (await rl.question(" 发布方式 [1] 公开(进入作品社区) [2] 私密(仅自己登录后可看,默认):")).trim().toLowerCase();
232
- if (!answer || answer === "2" || answer === "private" || answer === "私密")
233
- return "private";
234
- if (answer === "1" || answer === "public" || answer === "公开")
235
- return "public";
236
- console.log(" 请输入 1 或 2");
237
- }
238
- }
239
- finally {
240
- rl.close();
221
+ while (true) {
222
+ const answer = (await askLine(" 发布方式 [1] 公开(进入作品社区) [2] 私密(仅自己登录后可看,默认):")).toLowerCase();
223
+ if (!answer || answer === "2" || answer === "private" || answer === "私密")
224
+ return "private";
225
+ if (answer === "1" || answer === "public" || answer === "公开")
226
+ return "public";
227
+ console.log(" 请输入 1 2");
241
228
  }
242
229
  }
243
230
  async function listDeployments(cfg) {
@@ -376,5 +363,11 @@ export async function deployCommand(cfg, args) {
376
363
  },
377
364
  });
378
365
  rememberSite(selection.dir, start.slug || name || start.site);
366
+ // 部署过一次之后,会话里不再推「u1s1 deploy 一键上线」
367
+ markNudge("deploy_done");
379
368
  printDeploymentResult(result, start);
369
+ await sendTelemetryEvent(cfg, "deploy_done", {
370
+ public: result.visibility === "public",
371
+ file_count: result.file_count,
372
+ });
380
373
  }
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";
@@ -1,15 +1,3 @@
1
- /**
2
- * 模型调用失败时,网关的中文说明会被 OpenAI SDK + pi-ai 包成
3
- * `429: {"message":"…","type":"…","code":"…"}` 直出到聊天区。
4
- * 这里把 JSON 里的 message 提出来当正文,状态码与错误代号收进尾巴。
5
- *
6
- * 尾巴不是装饰,必须保留:pi 按 errorMessage 正则做三类分类——
7
- * - 重试:429/5xx 数字、rate_limit、fetch failed 等(retry.js RETRYABLE)
8
- * - 不重试:insufficient_quota(额度用尽时快速失败,不做无谓退避)
9
- * - 上下文超长:context_length_exceeded(触发自动压缩而非重试)
10
- * 改写后的文本必须与原文落进同一分类,否则会破坏重试/压缩行为。
11
- *
12
- * 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
13
- * 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
14
- */
1
+ /** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */
2
+ export declare function extractRequestId(raw: string): string | undefined;
15
3
  export declare function humanizeModelError(raw: string): string | undefined;
@@ -12,7 +12,7 @@
12
12
  * 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
13
13
  * 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
14
14
  */
15
- export function humanizeModelError(raw) {
15
+ function parseErrorPayload(raw) {
16
16
  const jsonStart = raw.indexOf("{");
17
17
  const jsonEnd = raw.lastIndexOf("}");
18
18
  if (jsonStart === -1 || jsonEnd <= jsonStart)
@@ -30,6 +30,19 @@ export function humanizeModelError(raw) {
30
30
  const err = root["error"] !== null && typeof root["error"] === "object"
31
31
  ? root["error"]
32
32
  : root;
33
+ return { err, jsonStart };
34
+ }
35
+ /** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */
36
+ export function extractRequestId(raw) {
37
+ const payload = parseErrorPayload(raw);
38
+ const id = payload?.err["request_id"];
39
+ return typeof id === "string" && id.length > 0 && id.length <= 128 ? id : undefined;
40
+ }
41
+ export function humanizeModelError(raw) {
42
+ const payload = parseErrorPayload(raw);
43
+ if (!payload)
44
+ return undefined;
45
+ const { err, jsonStart } = payload;
33
46
  const message = typeof err["message"] === "string" ? err["message"].trim() : "";
34
47
  if (!message)
35
48
  return undefined;
@@ -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[]>;
@@ -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
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
12
12
  import { AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "./api.js";
13
13
  import { ensureSigningProxy } from "./device-auth.js";
14
14
  import { installChildEnvGuard } from "./secret-env.js";
15
+ import { DEPLOY_HINT_TEXT, markNudge, shouldShowDeployHint } from "./nudges.js";
16
+ import { createGatewayTelemetry } from "./telemetry.js";
15
17
  const PACKAGE_NAME = "u1s1-cli";
16
18
  /** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
17
19
  let pendingUpdate;
@@ -257,6 +259,26 @@ async function runAgent(cfg, args) {
257
259
  scrubForeignProviderEnv();
258
260
  // 密钥只留在本进程:模型跑的 shell 命令、子 agent、扩展派生的任何子进程都拿不到
259
261
  installChildEnvGuard();
262
+ // 客户端事件上报(见 telemetry.ts):状态放在 runAgent 作用域,/clear、/resume
263
+ // 会重建扩展运行时、重跑下面的 factory,但一个 TUI 进程只算一个会话
264
+ const telemetry = createGatewayTelemetry(cfg);
265
+ const sessionStartedAt = Date.now();
266
+ const stats = { turns: 0, toolCalls: 0, filesWritten: 0, writesThisRun: 0 };
267
+ let firstToolSuccessSent = false;
268
+ let firstTaskDoneSent = false;
269
+ let sessionEndSent = false;
270
+ telemetry.track("session_start", { surface: "terminal" });
271
+ const trackSessionEnd = () => {
272
+ if (sessionEndSent)
273
+ return;
274
+ sessionEndSent = true;
275
+ telemetry.track("session_end", {
276
+ duration_ms: Date.now() - sessionStartedAt,
277
+ turns: stats.turns,
278
+ tool_calls: stats.toolCalls,
279
+ files_written: stats.filesWritten,
280
+ });
281
+ };
260
282
  await searchToolsReady;
261
283
  const { CustomEditor, main } = await agentReady;
262
284
  // 网页终端(如 Taikula)把 Shift+Enter 发成 ESC+CR(\x1b\r)。pi 在没开 Kitty
@@ -326,8 +348,10 @@ async function runAgent(cfg, args) {
326
348
  theme.fg("text", "直接打字说需求,回车发送 · Shift+Enter 换行 · Ctrl+V 贴图 · Esc 中断"),
327
349
  theme.fg("text", "/model 切换模型(留意免费/价格提示) · /clear 清空上下文开新会话"),
328
350
  theme.fg("text", "/usage 查剩余额度 · /resume 恢复历史会话 · /settings 设置"),
351
+ theme.fg("text", "/feedback 一句话反馈问题或建议(自动附带版本与请求编号建工单)"),
329
352
  theme.fg("dim", "/compact 压缩上下文 · /hotkeys 全部快捷键(英文) · /exit 退出"),
330
353
  theme.fg("dim", "退出后在终端:u1s1 deploy 发布网页 · u1s1 update 升级"),
354
+ theme.fg("dim", "匿名使用统计只记会话开始/结束等节点,不含任何内容;U1S1_TELEMETRY=0 可关闭"),
331
355
  theme.fg("dim", "新手教程 → https://u1s1.io/guides"),
332
356
  ].join("\n");
333
357
  return new Text(text, 1, 0);
@@ -352,6 +376,75 @@ async function runAgent(cfg, args) {
352
376
  pi.appendEntry("u1s1-usage", await usageEntryData());
353
377
  },
354
378
  });
379
+ // /feedback:会话内直接建工单,附版本/平台/最近请求编号(运营清单 E4)
380
+ pi.registerEntryRenderer("u1s1-feedback", (entry, _opts, theme) => {
381
+ const data = entry.data ?? {};
382
+ return new Text((data.lines ?? []).map((l) => theme.fg("text", l)).join("\n"), 1, 0);
383
+ });
384
+ pi.registerCommand("feedback", {
385
+ description: "反馈问题或建议(自动附带诊断信息建工单)",
386
+ handler: async (cmdArgs, ctx) => {
387
+ const { feedbackInSession, parseFeedbackArgs } = await import("./feedback.js");
388
+ let raw = cmdArgs.trim();
389
+ let parsed;
390
+ try {
391
+ parsed = parseFeedbackArgs(raw.split(/\s+/).filter(Boolean));
392
+ }
393
+ catch (e) {
394
+ pi.appendEntry("u1s1-feedback", { lines: [e instanceof Error ? e.message : String(e)] });
395
+ return;
396
+ }
397
+ if (!parsed.help && !parsed.text && ctx.hasUI) {
398
+ const text = (await ctx.ui.input("反馈内容", "一句话说清楚问题或建议"))?.trim();
399
+ if (!text)
400
+ return;
401
+ raw = `--${parsed.category} ${text}`;
402
+ }
403
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
404
+ pi.appendEntry("u1s1-feedback", { lines: await feedbackInSession(raw, model) });
405
+ },
406
+ });
407
+ // ---- 事件上报 + 首次写文件后的 deploy 提示 ----
408
+ pi.on("agent_start", () => {
409
+ stats.writesThisRun = 0;
410
+ });
411
+ pi.on("turn_end", () => {
412
+ stats.turns++;
413
+ });
414
+ pi.on("tool_result", (event) => {
415
+ stats.toolCalls++;
416
+ if (event.isError)
417
+ return;
418
+ if (!firstToolSuccessSent) {
419
+ firstToolSuccessSent = true;
420
+ telemetry.track("first_tool_success", { tool: event.toolName });
421
+ }
422
+ if (event.toolName === "write" || event.toolName === "edit") {
423
+ stats.filesWritten++;
424
+ stats.writesThisRun++;
425
+ }
426
+ });
427
+ pi.on("agent_end", (_event, ctx) => {
428
+ if (stats.writesThisRun === 0)
429
+ return;
430
+ if (!firstTaskDoneSent) {
431
+ firstTaskDoneSent = true;
432
+ telemetry.track("first_task_done", { files_written: stats.filesWritten });
433
+ }
434
+ // 每台机器只提示一次;跑过 u1s1 deploy 后永不再提(见 nudges.ts)
435
+ if (ctx.hasUI && shouldShowDeployHint()) {
436
+ markNudge("deploy_hint_shown");
437
+ ctx.ui.notify(DEPLOY_HINT_TEXT, "info");
438
+ }
439
+ });
440
+ // pi 在 session_shutdown 处理完后直接 process.exit,session_end 只能在这里
441
+ // 发并等冲刷(最多 1 秒);/clear、/resume 的 reason 不是 quit,不算会话结束
442
+ pi.on("session_shutdown", async (event) => {
443
+ if (event.reason !== "quit")
444
+ return;
445
+ trackSessionEnd();
446
+ await telemetry.shutdown();
447
+ });
355
448
  // 模型调用错误人话化不在这里注册:走 writeErrorHumanizeExtension 投影,
356
449
  // TUI 和 Desktop App 共用一份(见 agent-setup.ts)
357
450
  // /clear: 清空当前对话上下文,开始新会话
@@ -408,7 +501,13 @@ async function runAgent(cfg, args) {
408
501
  persistPreferredModel(cfg, pref.provider, pref.id);
409
502
  }
410
503
  const finalArgs = hasModelArg ? args : ["--model", `${pref.provider}/${pref.id}`, ...args];
411
- await main(finalArgs, { extensionFactories: extension });
504
+ try {
505
+ await main(finalArgs, { extensionFactories: extension });
506
+ }
507
+ finally {
508
+ trackSessionEnd();
509
+ await telemetry.shutdown();
510
+ }
412
511
  }
413
512
  async function run() {
414
513
  const args = process.argv.slice(2);
@@ -428,11 +527,13 @@ async function run() {
428
527
  console.log(" u1s1 update 升级到最新版");
429
528
  console.log(" u1s1 deploy 发布网页(--public / --private)");
430
529
  console.log(" u1s1 deploy list 查看已发布的站点");
530
+ console.log(" u1s1 feedback \"一句话\" 反馈问题或建议(自动建工单,--bug/--question…)");
431
531
  console.log(" u1s1 import 导入历史会话");
432
532
  console.log(" u1s1 bench 模型编码能力评测");
433
533
  console.log(" u1s1 --version 查看版本");
434
534
  console.log("");
435
- console.log(" 对话里输入 /help 看会话内命令(/model /clear /exit …)");
535
+ console.log(" 对话里输入 /help 看会话内命令(/model /clear /feedback /exit …)");
536
+ console.log(" 匿名使用统计只记会话开始/结束等节点,不含内容;设 U1S1_TELEMETRY=0 关闭");
436
537
  console.log(" 更多帮助 → https://u1s1.io/guides");
437
538
  return;
438
539
  }
@@ -463,6 +564,11 @@ async function run() {
463
564
  await usage();
464
565
  return;
465
566
  }
567
+ if (cmd === "feedback") {
568
+ const { feedbackCommand } = await import("./feedback.js");
569
+ await feedbackCommand(args.slice(1));
570
+ return;
571
+ }
466
572
  if (cmd === "model") {
467
573
  const { modelCommand } = await import("./model.js");
468
574
  await modelCommand(args[1]);
@@ -535,7 +641,7 @@ async function run() {
535
641
  // autoUpdate 开着且启动时发现了新版:现在装(TUI 已退出,原地重铺安全)
536
642
  installPendingUpdate();
537
643
  }
538
- const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "model", "update", "import", "bench", "help", "web"];
644
+ const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "model", "update", "import", "bench", "feedback", "help", "web"];
539
645
  /** 经典 Levenshtein,命令名都很短,O(nm) 足够。 */
540
646
  function editDistance(a, b) {
541
647
  const dp = Array.from({ length: a.length + 1 }, (_, i) => i);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 「只提示一次」的机器级标记,落在 ~/.u1s1/nudges.json,CLI 与桌面版共用
3
+ * (桌面版经 u1s1-cli/embed 拿到同一份 helper)。值是 ISO 时间戳,便于事后
4
+ * 排查「什么时候提示过」。读写都是尽力而为:文件坏了当作没提示过,写失败
5
+ * 最多是多提示一次,绝不能因为它抛错影响主流程。
6
+ */
7
+ export type NudgeKey =
8
+ /** 首次写文件后打印过「u1s1 deploy 一键上线」提示 */
9
+ "deploy_hint_shown"
10
+ /** 用户成功跑过一次 u1s1 deploy(之后不再推 deploy) */
11
+ | "deploy_done"
12
+ /** 桌面版首启引导已看过 */
13
+ | "desktop_onboarding_done";
14
+ export type NudgeState = Partial<Record<NudgeKey, string>>;
15
+ export declare const nudgesFile: string;
16
+ export declare function readNudges(file?: string): NudgeState;
17
+ export declare function hasNudge(key: NudgeKey, file?: string): boolean;
18
+ export declare function markNudge(key: NudgeKey, file?: string): void;
19
+ /** 会话里首次写/改文件后要不要提示 deploy:没提示过、也没部署过才提示。 */
20
+ export declare function shouldShowDeployHint(file?: string): boolean;
21
+ export declare const DEPLOY_HINT_TEXT = "\u505A\u597D\u4E86\u60F3\u7ED9\u670B\u53CB\u770B\uFF1F\u8FD0\u884C u1s1 deploy \u4E00\u952E\u4E0A\u7EBF";
package/dist/nudges.js ADDED
@@ -0,0 +1,40 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { u1s1Dir } from "./config.js";
4
+ export const nudgesFile = join(u1s1Dir, "nudges.json");
5
+ export function readNudges(file = nudgesFile) {
6
+ try {
7
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
8
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
9
+ return {};
10
+ const out = {};
11
+ for (const [k, v] of Object.entries(parsed)) {
12
+ if (typeof v === "string")
13
+ out[k] = v;
14
+ }
15
+ return out;
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ export function hasNudge(key, file = nudgesFile) {
22
+ return readNudges(file)[key] !== undefined;
23
+ }
24
+ export function markNudge(key, file = nudgesFile) {
25
+ try {
26
+ mkdirSync(join(file, ".."), { recursive: true });
27
+ const all = readNudges(file);
28
+ all[key] = new Date().toISOString();
29
+ writeFileSync(file, JSON.stringify(all, null, 2) + "\n");
30
+ }
31
+ catch {
32
+ // ignore
33
+ }
34
+ }
35
+ /** 会话里首次写/改文件后要不要提示 deploy:没提示过、也没部署过才提示。 */
36
+ export function shouldShowDeployHint(file = nudgesFile) {
37
+ const state = readNudges(file);
38
+ return state.deploy_hint_shown === undefined && state.deploy_done === undefined;
39
+ }
40
+ export const DEPLOY_HINT_TEXT = "做好了想给朋友看?运行 u1s1 deploy 一键上线";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 终端里问一行:非 TTY(脚本/管道)直接返回默认值,不挂住。
3
+ * deploy 问站点名、feedback 问反馈内容都走这里。
4
+ */
5
+ export declare function askLine(question: string, def?: string): Promise<string>;
package/dist/prompt.js ADDED
@@ -0,0 +1,17 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ /**
3
+ * 终端里问一行:非 TTY(脚本/管道)直接返回默认值,不挂住。
4
+ * deploy 问站点名、feedback 问反馈内容都走这里。
5
+ */
6
+ export async function askLine(question, def = "") {
7
+ if (!process.stdin.isTTY)
8
+ return def;
9
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
10
+ try {
11
+ const answer = (await rl.question(question)).trim();
12
+ return answer || def;
13
+ }
14
+ finally {
15
+ rl.close();
16
+ }
17
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 记住最近一次模型调用报错带回的 request_id(网关在错误 JSON 里注入,见
3
+ * error-humanize.ts),`u1s1 feedback` / 会话内 /feedback 建工单时附进诊断
4
+ * 快照,客服凭它直查日志。进程内先记内存;同时尽力落到 ~/.u1s1/last-request.json,
5
+ * 让退出会话后再跑 `u1s1 feedback` 的用户也能带上编号。超过 24 小时的记录
6
+ * 不再当「最近」。
7
+ */
8
+ export declare const lastRequestFile: string;
9
+ export declare function rememberRequestId(id: string | undefined, file?: string): void;
10
+ export declare function lastRequestId(file?: string, now?: number): string | undefined;
11
+ /** 测试用:清掉进程内缓存。 */
12
+ export declare function resetRequestTraceForTests(): void;
@@ -0,0 +1,45 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { u1s1Dir } from "./config.js";
4
+ /**
5
+ * 记住最近一次模型调用报错带回的 request_id(网关在错误 JSON 里注入,见
6
+ * error-humanize.ts),`u1s1 feedback` / 会话内 /feedback 建工单时附进诊断
7
+ * 快照,客服凭它直查日志。进程内先记内存;同时尽力落到 ~/.u1s1/last-request.json,
8
+ * 让退出会话后再跑 `u1s1 feedback` 的用户也能带上编号。超过 24 小时的记录
9
+ * 不再当「最近」。
10
+ */
11
+ export const lastRequestFile = join(u1s1Dir, "last-request.json");
12
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
13
+ let inMemory;
14
+ export function rememberRequestId(id, file = lastRequestFile) {
15
+ if (!id || id.length > 128)
16
+ return;
17
+ const at = Date.now();
18
+ inMemory = { request_id: id, at };
19
+ try {
20
+ writeFileSync(file, JSON.stringify({ request_id: id, at: new Date(at).toISOString() }) + "\n");
21
+ }
22
+ catch {
23
+ // ~/.u1s1 不存在(纯环境变量登录)等情况:只记内存
24
+ }
25
+ }
26
+ export function lastRequestId(file = lastRequestFile, now = Date.now()) {
27
+ if (inMemory && now - inMemory.at <= MAX_AGE_MS)
28
+ return inMemory.request_id;
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
31
+ if (typeof parsed.request_id !== "string" || typeof parsed.at !== "string")
32
+ return undefined;
33
+ const at = Date.parse(parsed.at);
34
+ if (!Number.isFinite(at) || now - at > MAX_AGE_MS)
35
+ return undefined;
36
+ return parsed.request_id;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** 测试用:清掉进程内缓存。 */
43
+ export function resetRequestTraceForTests() {
44
+ inMemory = undefined;
45
+ }
@@ -0,0 +1,56 @@
1
+ import { type CliConfig } from "./config.js";
2
+ /**
3
+ * 客户端事件上报(运营清单 C1):只报「会话开始/结束、首次工具成功、首次任务
4
+ * 完成、部署完成」几个节点,不采任何内容。事件先在内存排队,每 30 秒或退出时
5
+ * 批量 POST /v1/telemetry(每批最多 20 条),失败静默丢弃,绝不阻塞界面、
6
+ * 绝不拖慢退出(退出冲刷最多等 1 秒)。
7
+ *
8
+ * 关闭方式:环境变量 U1S1_TELEMETRY=0,或 ~/.u1s1/agent/settings.json 里
9
+ * "telemetry": false。关闭后 track 直接丢,不打印任何东西。
10
+ */
11
+ export type TelemetryEventName = "session_start" | "session_end" | "first_tool_success" | "first_task_done" | "deploy_done";
12
+ export interface TelemetryEvent {
13
+ name: TelemetryEventName;
14
+ /** epoch ms */
15
+ ts: number;
16
+ props?: Record<string, unknown>;
17
+ }
18
+ export type TelemetryTransport = (events: TelemetryEvent[]) => Promise<void>;
19
+ export interface TelemetryOptions {
20
+ transport: TelemetryTransport;
21
+ /** 默认 true;false 时所有 track 都是空操作 */
22
+ enabled?: boolean;
23
+ /** 定时冲刷间隔,默认 30 秒 */
24
+ flushIntervalMs?: number;
25
+ /** 单批上限,默认 20(与网关契约一致) */
26
+ maxBatch?: number;
27
+ /** 退出冲刷的等待上限,默认 1 秒 */
28
+ shutdownTimeoutMs?: number;
29
+ now?: () => number;
30
+ }
31
+ export interface Telemetry {
32
+ track(name: TelemetryEventName, props?: Record<string, unknown>): void;
33
+ /** 立即把队列里的事件按批发出;传输失败静默丢弃 */
34
+ flush(): Promise<void>;
35
+ /** 停掉定时器并做最后一次冲刷(最多等 shutdownTimeoutMs) */
36
+ shutdown(): Promise<void>;
37
+ /** 当前排队条数(测试用) */
38
+ pending(): number;
39
+ readonly enabled: boolean;
40
+ }
41
+ export declare const TELEMETRY_FLUSH_INTERVAL_MS = 30000;
42
+ export declare const TELEMETRY_MAX_BATCH = 20;
43
+ export declare const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1000;
44
+ /** 环境变量优先于设置文件;两者都没说就默认开启。 */
45
+ export declare function telemetryEnabled(env?: NodeJS.ProcessEnv, settings?: {
46
+ telemetry?: unknown;
47
+ }): boolean;
48
+ export declare function createTelemetry(options: TelemetryOptions): Telemetry;
49
+ /** 真正的传输:设备签名 POST /v1/telemetry,3 秒超时,响应体直接丢。 */
50
+ export declare function gatewayTransport(cfg: CliConfig, timeoutMs?: number): TelemetryTransport;
51
+ /** 按当前环境/设置组装一个连着网关的 Telemetry。 */
52
+ export declare function createGatewayTelemetry(cfg: CliConfig, overrides?: Partial<TelemetryOptions>): Telemetry;
53
+ /**
54
+ * 一次性命令(如 deploy)用:发一个事件就冲刷,最多等 1 秒;任何失败都吞掉。
55
+ */
56
+ export declare function sendTelemetryEvent(cfg: CliConfig, name: TelemetryEventName, props?: Record<string, unknown>): Promise<void>;
@@ -0,0 +1,146 @@
1
+ import { readSettings, VERSION } from "./config.js";
2
+ import { authorizedFetch } from "./device-auth.js";
3
+ export const TELEMETRY_FLUSH_INTERVAL_MS = 30_000;
4
+ export const TELEMETRY_MAX_BATCH = 20;
5
+ export const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1_000;
6
+ /** 内存里最多攒这么多条;再多就丢最旧的,防止网络长期不通时无限增长 */
7
+ const MAX_QUEUE = 200;
8
+ /** 环境变量优先于设置文件;两者都没说就默认开启。 */
9
+ export function telemetryEnabled(env = process.env, settings = readSettings()) {
10
+ const raw = env["U1S1_TELEMETRY"];
11
+ if (raw !== undefined && raw !== "") {
12
+ const v = raw.trim().toLowerCase();
13
+ if (v === "0" || v === "false" || v === "off" || v === "no")
14
+ return false;
15
+ if (v === "1" || v === "true" || v === "on" || v === "yes")
16
+ return true;
17
+ }
18
+ return settings.telemetry !== false;
19
+ }
20
+ export function createTelemetry(options) {
21
+ const enabled = options.enabled ?? true;
22
+ const flushIntervalMs = options.flushIntervalMs ?? TELEMETRY_FLUSH_INTERVAL_MS;
23
+ const maxBatch = options.maxBatch ?? TELEMETRY_MAX_BATCH;
24
+ const shutdownTimeoutMs = options.shutdownTimeoutMs ?? TELEMETRY_SHUTDOWN_TIMEOUT_MS;
25
+ const now = options.now ?? Date.now;
26
+ const queue = [];
27
+ let timer;
28
+ let inflight;
29
+ let stopped = false;
30
+ const clearTimer = () => {
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ timer = undefined;
34
+ };
35
+ const flushNow = async () => {
36
+ clearTimer();
37
+ // 串行发送:上一批还在路上就等它,避免乱序与并发放大
38
+ if (inflight)
39
+ await inflight;
40
+ inflight = (async () => {
41
+ // 一次 flush 最多发几批,剩下的留给下一轮,别把退出拖住
42
+ for (let round = 0; round < 3 && queue.length > 0; round++) {
43
+ const batch = queue.splice(0, maxBatch);
44
+ try {
45
+ await options.transport(batch);
46
+ }
47
+ catch {
48
+ // 静默丢弃:遥测不值得重试,更不值得打扰用户
49
+ }
50
+ }
51
+ })();
52
+ try {
53
+ await inflight;
54
+ }
55
+ finally {
56
+ inflight = undefined;
57
+ }
58
+ };
59
+ const schedule = () => {
60
+ if (timer || stopped)
61
+ return;
62
+ timer = setTimeout(() => {
63
+ timer = undefined;
64
+ void flushNow();
65
+ }, flushIntervalMs);
66
+ // 定时器不能拖住进程退出
67
+ timer.unref?.();
68
+ };
69
+ return {
70
+ enabled,
71
+ track(name, props) {
72
+ if (!enabled || stopped)
73
+ return;
74
+ queue.push(props ? { name, ts: now(), props } : { name, ts: now() });
75
+ if (queue.length > MAX_QUEUE)
76
+ queue.splice(0, queue.length - MAX_QUEUE);
77
+ if (queue.length >= maxBatch)
78
+ void flushNow();
79
+ else
80
+ schedule();
81
+ },
82
+ async flush() {
83
+ if (!enabled)
84
+ return;
85
+ await flushNow();
86
+ },
87
+ async shutdown() {
88
+ if (!enabled || stopped)
89
+ return;
90
+ stopped = true;
91
+ clearTimer();
92
+ if (queue.length === 0 && !inflight)
93
+ return;
94
+ let cap;
95
+ const deadline = new Promise((resolve) => {
96
+ cap = setTimeout(resolve, shutdownTimeoutMs);
97
+ cap.unref?.();
98
+ });
99
+ try {
100
+ await Promise.race([flushNow(), deadline]);
101
+ }
102
+ finally {
103
+ if (cap)
104
+ clearTimeout(cap);
105
+ }
106
+ },
107
+ pending() {
108
+ return queue.length;
109
+ },
110
+ };
111
+ }
112
+ /** 真正的传输:设备签名 POST /v1/telemetry,3 秒超时,响应体直接丢。 */
113
+ export function gatewayTransport(cfg, timeoutMs = 3_000) {
114
+ return async (events) => {
115
+ const resp = await authorizedFetch(cfg, `${cfg.baseUrl}/telemetry`, {
116
+ method: "POST",
117
+ headers: { "x-u1s1-version": VERSION, "content-type": "application/json" },
118
+ body: JSON.stringify({ events }),
119
+ signal: AbortSignal.timeout(timeoutMs),
120
+ });
121
+ void resp.body?.cancel("telemetry response ignored").catch(() => { });
122
+ };
123
+ }
124
+ /** 按当前环境/设置组装一个连着网关的 Telemetry。 */
125
+ export function createGatewayTelemetry(cfg, overrides = {}) {
126
+ return createTelemetry({
127
+ transport: gatewayTransport(cfg),
128
+ enabled: telemetryEnabled(),
129
+ ...overrides,
130
+ });
131
+ }
132
+ /**
133
+ * 一次性命令(如 deploy)用:发一个事件就冲刷,最多等 1 秒;任何失败都吞掉。
134
+ */
135
+ export async function sendTelemetryEvent(cfg, name, props) {
136
+ try {
137
+ const t = createGatewayTelemetry(cfg);
138
+ if (!t.enabled)
139
+ return;
140
+ t.track(name, props);
141
+ await t.shutdown();
142
+ }
143
+ catch {
144
+ // 遥测永远不影响命令本身
145
+ }
146
+ }
package/dist/update.js CHANGED
@@ -166,8 +166,8 @@ async function portableSelfUpdate(latest) {
166
166
  execFileSync("bash", [tmp], { stdio: "inherit" });
167
167
  }
168
168
  catch {
169
- console.error("\n自动升级失败。请手动运行:");
170
- console.error(" curl -fsSL https://u1s1.io/releases/install.sh | bash");
169
+ // 安装脚本已经打印了失败原因与重试提示(多半是到 u1s1.io 的链路慢),这里不再重复一整段
170
+ console.error("\n自动升级没有完成,稍后重试 u1s1 update 即可;已安装版本不受影响。");
171
171
  process.exit(1);
172
172
  }
173
173
  // 脚本退出码 0 ≠ 升级成功:安装脚本认的是官网 LATEST 版本源,它若认为
package/dist/usage.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { type MeResponse } from "./api.js";
2
+ export declare const TOPUP_URL = "https://u1s1.io/dashboard#usage-topup-card";
3
+ /** 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。 */
4
+ export declare function usageCtaLines(): string[];
2
5
  /** 额度报告正文(不含首尾空行),终端 `u1s1 usage` 和会话内 /usage 共用。 */
3
6
  export declare function usageReportLines(me: MeResponse): string[];
4
7
  /** 会话内 /usage 用:拉取额度并打包成 entry data,错误转成中文提示而不是抛出。 */
package/dist/usage.js CHANGED
@@ -93,6 +93,8 @@ function packageUsageLines(me, tokensPerUsd) {
93
93
  lines.push(" 免费用量包覆盖 DeepSeek V4 Flash / Vision、GLM-5.3 Flash、Qwen3.8 Flash 和联网搜索。");
94
94
  lines.push(" 其他模型(如 DeepSeek V4 Pro)不走免费包,可用余额或全模型包 → https://u1s1.io/dashboard");
95
95
  lines.push(" Token 数按默认模型单价折算,为约数;用更贵的模型时消耗更快。");
96
+ lines.push("");
97
+ lines.push(...usageCtaLines());
96
98
  return lines;
97
99
  }
98
100
  function legacyUsageLines(me, tokensPerUsd) {
@@ -116,9 +118,17 @@ function legacyUsageLines(me, tokensPerUsd) {
116
118
  lines.push(` 本月成本 $${me.mtd_usd.toFixed(2)}`);
117
119
  }
118
120
  lines.push("");
119
- lines.push(" 免费额度每天自动恢复;邀请朋友双方各得永久加量 → https://u1s1.io/dashboard");
121
+ lines.push(...usageCtaLines());
120
122
  return lines;
121
123
  }
124
+ export const TOPUP_URL = "https://u1s1.io/dashboard#usage-topup-card";
125
+ /** 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。 */
126
+ export function usageCtaLines() {
127
+ return [
128
+ " 免费额度每天自动恢复;邀请朋友双方各得永久加量 → https://u1s1.io/dashboard",
129
+ ` 额度不够用?充值 → ${TOPUP_URL}`,
130
+ ];
131
+ }
122
132
  /** 额度报告正文(不含首尾空行),终端 `u1s1 usage` 和会话内 /usage 共用。 */
123
133
  export function usageReportLines(me) {
124
134
  const tokensPerUsd = me.tokens_per_usd ?? 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {