dsh-codearts 1.2.9 → 1.2.11

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/lib/index.d.mts CHANGED
@@ -787,6 +787,12 @@ interface CodeArtsModelInfo {
787
787
  id: string;
788
788
  name: string;
789
789
  contextWindow: number;
790
+ /**
791
+ * `contextWindow` 是上游真的给的,还是兜底值(32768)。
792
+ *
793
+ * 卡片只在上游给值时印「1M 上下文」这类规格;兜底值印出来就是假数据。
794
+ */
795
+ contextWindowKnown?: boolean;
790
796
  maxTokens: number;
791
797
  supportsImages: boolean;
792
798
  /**
@@ -1013,6 +1019,15 @@ interface CodeArtsCredential {
1013
1019
  storedAt?: number;
1014
1020
  /** 续期已尝试且失败(多半已过期),需用户重新登录。 */
1015
1021
  stale?: boolean;
1022
+ /**
1023
+ * 用户手动暂停:凭证还在、也未必过期,只是暂时不想用它。
1024
+ *
1025
+ * 和 `stale` 分开:`stale` 是「这个号坏了,必须重新登录」,这里只是「先别派活」。
1026
+ * 恢复时不需要重新登录,也不会清掉池里的禁用/冷却状态。
1027
+ */
1028
+ paused?: boolean;
1029
+ /** 用户置顶:轮转时优先用这个号(仍受冷却与并发槽位约束)。 */
1030
+ pinned?: boolean;
1016
1031
  }
1017
1032
  declare function codeartsAuthPath(home?: string): string;
1018
1033
  interface ResolvedCredential {
@@ -1064,6 +1079,10 @@ interface CodeArtsAccountSummary {
1064
1079
  stale: boolean;
1065
1080
  /** 带 AK/SK,能产生签名(只有 security_token 的降级凭证不能)。 */
1066
1081
  signable: boolean;
1082
+ /** 用户手动暂停(不参与轮转)。 */
1083
+ paused: boolean;
1084
+ /** 用户置顶(轮转优先)。 */
1085
+ pinned: boolean;
1067
1086
  }
1068
1087
  declare function codeartsAccountsPath(home?: string): string;
1069
1088
  /** 多账号凭证存储:一个账号一份槽位,共用一个文档文件。 */
@@ -1113,6 +1132,21 @@ declare class CodeArtsAccountStore {
1113
1132
  /** 手工粘贴 security_token(降级态,无 AK/SK)——固定覆盖同一个「手工」账号。 */
1114
1133
  set(token: string, label?: string): Promise<void>;
1115
1134
  remove(id: string): Promise<void>;
1135
+ /**
1136
+ * 暂停 / 恢复一个账号。
1137
+ *
1138
+ * 只改标记,不碰凭证:恢复时不需要重新登录。重新装配槽位是因为 AccountSlot
1139
+ * 缓存的 `_cred` 是旧对象,续期回写会把它盖回来 —— 换了对象,续期写到新文档里。
1140
+ */
1141
+ setPaused(id: string, paused: boolean): Promise<boolean>;
1142
+ /** 置顶 / 取消置顶:只改变轮转先后,不绕过冷却与并发上限。 */
1143
+ setPinned(id: string, pinned: boolean): Promise<boolean>;
1144
+ /** shim 用:参与轮转的账号 id(排除用户暂停的)。 */
1145
+ dispatchIds(): Promise<string[]>;
1146
+ /** 被用户暂停的账号 id(账号池据此保留状态但不派活)。 */
1147
+ pausedIds(): Promise<string[]>;
1148
+ /** 置顶账号的 id 列表(给账号池排序用)。 */
1149
+ pinnedIds(): Promise<string[]>;
1116
1150
  /** 清空全部账号(整体登出)。 */
1117
1151
  clear(): Promise<void>;
1118
1152
  /** 给 shim 用:解析某账号的签名材料(内部含续期)。 */
@@ -1149,6 +1183,10 @@ interface PoolOptions {
1149
1183
  }
1150
1184
  declare class CodeArtsAccountPool {
1151
1185
  private readonly accounts;
1186
+ /** 用户手动暂停的账号:运行时状态留着(卡片还看得见槽位),但不参与轮转。 */
1187
+ private readonly paused;
1188
+ /** 用户置顶的账号:选号时排在最前面,不再按「最久未用」轮它。 */
1189
+ private readonly preferred;
1152
1190
  private readonly maxConcurrent;
1153
1191
  private readonly now;
1154
1192
  constructor(ids: readonly string[], options?: PoolOptions);
@@ -1158,8 +1196,12 @@ declare class CodeArtsAccountPool {
1158
1196
  get(id: string): AccountRuntime | undefined;
1159
1197
  /** 账号本身可用(没被禁用、不在冷却中)。 */
1160
1198
  healthy(id: string): boolean;
1161
- /** 现在就能接活的账号(健康 + 本地还有空槽位),按「最久未用」排序。 */
1199
+ /** 现在就能接活的账号(健康 + 未暂停 + 本地还有空槽位),置顶的优先,其余按「最久未用」。 */
1162
1200
  private available;
1201
+ /** 设置暂停集合(账号 id)。与账号文档同步时调用。 */
1202
+ setPaused(ids: readonly string[]): void;
1203
+ /** 设置置顶集合(账号 id)。置顶只在「同时可用」时改变先后,不会绕过冷却/上限。 */
1204
+ setPreferred(ids: readonly string[]): void;
1163
1205
  /**
1164
1206
  * 选一个账号。
1165
1207
  *
@@ -1345,6 +1387,24 @@ interface UsageSnapshot {
1345
1387
  }[];
1346
1388
  /** 今天按账号的合计。 */
1347
1389
  byAccountToday: Record<string, UsageTotals>;
1390
+ /** 今天的失败次数:总数 + 按错误类别。 */
1391
+ failuresToday: {
1392
+ total: number;
1393
+ byKind: Record<string, number>;
1394
+ };
1395
+ /** 今天按账号的失败次数。 */
1396
+ failuresByAccountToday: Record<string, number>;
1397
+ /**
1398
+ * 今天的首字延迟分位(毫秒)。样本为 0 时 p50/p95 缺省。
1399
+ *
1400
+ * `overflow` 表示 P95 落进了「>30 秒」那一档,前端据此显示「30s+」。
1401
+ */
1402
+ latencyToday: {
1403
+ samples: number;
1404
+ p50?: number;
1405
+ p95?: number;
1406
+ overflow: boolean;
1407
+ };
1348
1408
  /** 账本覆盖的天数(便于「数据从哪天开始」的说明)。 */
1349
1409
  days: number;
1350
1410
  /**
@@ -1405,6 +1465,20 @@ declare class CodeArtsUsageTracker {
1405
1465
  noteUsage(accountId: string, model: string, raw: RawUsage | undefined, at?: number): void;
1406
1466
  /** 一天里的逐小时桶,第一次写的时候才建(老账本没有这个键)。 */
1407
1467
  private hoursOf;
1468
+ /**
1469
+ * 记一次失败请求。
1470
+ *
1471
+ * 只在「这次请求最终回给用户一个错误」时调用一次 —— 插件内部换号重试不算失败,
1472
+ * 否则一个排队重试就能把失败率刷成好几倍。`kind` 用 shim 的失败出口标识。
1473
+ */
1474
+ noteFailure(kind: string, accountId?: string, at?: number): void;
1475
+ /**
1476
+ * 记一次首字延迟(毫秒),落进直方图。
1477
+ *
1478
+ * 从请求进入 shim 算起,到上游第一帧真的写给 DSH 为止 —— 排队和换号的时间都算进去,
1479
+ * 因为那正是用户盯着转圈等掉的时长。
1480
+ */
1481
+ noteLatency(ms: number, at?: number): void;
1408
1482
  /**
1409
1483
  * 丢弃保留期之外的天(按日期键比较即可,YYYY-MM-DD 是字典序)。
1410
1484
  *
package/lib/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as upstreamErrorMessage, B as modelUnavailableReason, C as isWelfareClaimedStatus, D as prepareChatBody, E as pickDailyWelfareCampaign, F as filterEnabledModels, G as codeartsAuthPath, H as CodeArtsCredentialStore, I as forgetModelUnavailable, K as startLogin, L as isBenefitBlocked, M as FALLBACK_CODEARTS_MODELS, N as clearBenefitBlocked, O as rateLimitFrame, P as creditRatioLabel, R as isBenefitModel, S as isTpmLimit, T as modelUnavailableReasonText, U as MANUAL_ACCOUNT_ID, V as CodeArtsAccountStore, W as codeartsAccountsPath, _ as isIamTokenError, a as SNAP_HOST, b as isParamInvalidFrame, c as claimDailyWelfare, d as fetchCallerIdentity, f as fetchModels, g as isConcurrencyCap, h as firstFrameError, i as refreshCatalog, j as CodeArtsCatalog, k as signedGet, l as clearQueueStatus, m as fetchWelfareCampaigns, n as codeartsUsagePath, o as canonicalModel, p as fetchQueueStatus, r as discoverModels, s as chatStream, t as CodeArtsUsageTracker, u as degradeTokenCeiling, v as isModelNotAccessible, w as maxTokensCeilingFor, x as isQueueableRate, y as isNotMultimodal, z as markModelUnavailable } from "./usage-B2LNsUuR.mjs";
1
+ import { A as signedGet, B as markModelUnavailable, C as isTpmLimit, D as pickDailyWelfareCampaign, E as modelUnavailableReasonText, F as creditRatioLabel, G as codeartsAccountsPath, H as CodeArtsAccountStore, I as filterEnabledModels, J as startLogin, K as codeartsAuthPath, L as forgetModelUnavailable, M as CodeArtsCatalog, N as FALLBACK_CODEARTS_MODELS, O as prepareChatBody, P as clearBenefitBlocked, R as isBenefitBlocked, S as isQueueableRate, T as maxTokensCeilingFor, U as CodeArtsCredentialStore, V as modelUnavailableReason, W as MANUAL_ACCOUNT_ID, _ as isConcurrencyCap, a as refreshCatalog, b as isNotMultimodal, c as chatStream, d as degradeTokenCeiling, f as fetchCallerIdentity, g as firstFrameError, h as fetchWelfareCampaigns, i as discoverModels, j as upstreamErrorMessage, k as rateLimitFrame, l as claimDailyWelfare, m as fetchQueueStatus, n as CodeArtsUsageTracker, o as SNAP_HOST, p as fetchModels, q as credentialFromOAuth, r as codeartsUsagePath, s as canonicalModel, t as CODEARTS_VERSION, u as clearQueueStatus, v as isIamTokenError, w as isWelfareClaimedStatus, x as isParamInvalidFrame, y as isModelNotAccessible, z as isBenefitModel } from "./version-Dq3wXMop.mjs";
2
2
  import { i as lazyApi, r as createProvider } from "./models-CgKCibpk.mjs";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { randomBytes, timingSafeEqual } from "node:crypto";
@@ -16,6 +16,10 @@ import * as settingsModule from "@deepseek-ai/dsh-settings";
16
16
  const CAPPED_HINT_MS = 3e4;
17
17
  var CodeArtsAccountPool = class {
18
18
  accounts = /* @__PURE__ */ new Map();
19
+ /** 用户手动暂停的账号:运行时状态留着(卡片还看得见槽位),但不参与轮转。 */
20
+ paused = /* @__PURE__ */ new Set();
21
+ /** 用户置顶的账号:选号时排在最前面,不再按「最久未用」轮它。 */
22
+ preferred = /* @__PURE__ */ new Set();
19
23
  maxConcurrent;
20
24
  now;
21
25
  constructor(ids, options = {}) {
@@ -53,10 +57,23 @@ var CodeArtsAccountPool = class {
53
57
  if (account === void 0) return false;
54
58
  return !account.disabled && this.now() >= account.coolUntil;
55
59
  }
56
- /** 现在就能接活的账号(健康 + 本地还有空槽位),按「最久未用」排序。 */
60
+ /** 现在就能接活的账号(健康 + 未暂停 + 本地还有空槽位),置顶的优先,其余按「最久未用」。 */
57
61
  available(exclude) {
58
62
  const now = this.now();
59
- return this.list().filter((a) => !a.disabled && now >= a.coolUntil && !exclude.has(a.id) && a.activeConcurrent < a.maxConcurrent).sort((a, b) => a.lastUsed - b.lastUsed);
63
+ return this.list().filter((a) => !a.disabled && !this.paused.has(a.id) && now >= a.coolUntil && !exclude.has(a.id) && a.activeConcurrent < a.maxConcurrent).sort((a, b) => {
64
+ const pinned = Number(this.preferred.has(b.id)) - Number(this.preferred.has(a.id));
65
+ return pinned !== 0 ? pinned : a.lastUsed - b.lastUsed;
66
+ });
67
+ }
68
+ /** 设置暂停集合(账号 id)。与账号文档同步时调用。 */
69
+ setPaused(ids) {
70
+ this.paused.clear();
71
+ for (const id of ids) this.paused.add(id);
72
+ }
73
+ /** 设置置顶集合(账号 id)。置顶只在「同时可用」时改变先后,不会绕过冷却/上限。 */
74
+ setPreferred(ids) {
75
+ this.preferred.clear();
76
+ for (const id of ids) this.preferred.add(id);
60
77
  }
61
78
  /**
62
79
  * 选一个账号。
@@ -1198,7 +1215,10 @@ function createCodeArtsShim(options) {
1198
1215
  }
1199
1216
  }
1200
1217
  async function chatCompletions(req, res) {
1218
+ /** 首字延迟的起点:从读完请求头就开始算,排队与换号的时间都算「用户等了多久」。 */
1219
+ const requestStartedAt = Date.now();
1201
1220
  if (!isJsonContentType(req)) {
1221
+ usage?.noteFailure("media_type");
1202
1222
  writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
1203
1223
  return;
1204
1224
  }
@@ -1211,6 +1231,7 @@ function createCodeArtsShim(options) {
1211
1231
  const checked = prepareChatBody(raw, contextWindow);
1212
1232
  noteRequestShape(raw, checked.model);
1213
1233
  if (checked.error !== void 0) {
1234
+ usage?.noteFailure("context_overflow");
1214
1235
  writeOpenAIError(res, 400, "context_overflow", checked.error);
1215
1236
  return;
1216
1237
  }
@@ -1220,12 +1241,19 @@ function createCodeArtsShim(options) {
1220
1241
  });
1221
1242
  let ids;
1222
1243
  try {
1223
- ids = await store.ids();
1244
+ const all = await store.ids();
1245
+ const paused = new Set(await store.pausedIds());
1246
+ pool.sync(all);
1247
+ pool.setPaused([...paused]);
1248
+ pool.setPreferred(await store.pinnedIds());
1249
+ ids = all.filter((id) => !paused.has(id));
1224
1250
  } catch (error) {
1251
+ usage?.noteFailure("not_signed_in");
1225
1252
  writeOpenAIError(res, 401, "not_signed_in", String(error));
1226
1253
  return;
1227
1254
  }
1228
1255
  if (ids.length === 0) {
1256
+ usage?.noteFailure("not_signed_in");
1229
1257
  writeOpenAIError(res, 401, "not_signed_in", "no CodeArts account stored; sign in from the CodeArts settings card");
1230
1258
  return;
1231
1259
  }
@@ -1237,6 +1265,7 @@ function createCodeArtsShim(options) {
1237
1265
  let tpmAccount;
1238
1266
  let queueWaitedMs = 0;
1239
1267
  let lastKind;
1268
+ let lastAccountId;
1240
1269
  let lastStatus = 0;
1241
1270
  let lastMessage = "";
1242
1271
  for (let attempt = 0; attempt < MAX_ROTATE; attempt += 1) {
@@ -1254,10 +1283,12 @@ function createCodeArtsShim(options) {
1254
1283
  const gate = await queue.wait(credential, checked.chatId, checked.model, controller.signal);
1255
1284
  if (controller.signal.aborted) return;
1256
1285
  if (gate.kind === "queued") {
1286
+ usage?.noteFailure("queued", id);
1257
1287
  writeOpenAIError(res, 400, "queued", queuedMessage(checked.model, gate));
1258
1288
  return;
1259
1289
  }
1260
1290
  if (gate.kind === "queue_full" || gate.kind === "error") {
1291
+ usage?.noteFailure(gate.kind, id);
1261
1292
  writeOpenAIError(res, gate.kind === "queue_full" ? 429 : 502, gate.kind, gate.message);
1262
1293
  return;
1263
1294
  }
@@ -1272,11 +1303,13 @@ function createCodeArtsShim(options) {
1272
1303
  pool.noteSuccess(id);
1273
1304
  await pipeToResponse(res, result.stream, controller.signal, {
1274
1305
  accountId: id,
1275
- model: prepared.model
1306
+ model: prepared.model,
1307
+ startedAt: requestStartedAt
1276
1308
  }, opened.head);
1277
1309
  return;
1278
1310
  }
1279
1311
  lastKind = result.kind;
1312
+ lastAccountId = id;
1280
1313
  lastStatus = result.status;
1281
1314
  lastMessage = result.message;
1282
1315
  if (isConcurrencyCap(result)) {
@@ -1331,11 +1364,13 @@ function createCodeArtsShim(options) {
1331
1364
  pool.noteSuccess(queueAccount);
1332
1365
  await pipeToResponse(res, result.stream, controller.signal, {
1333
1366
  accountId: queueAccount,
1334
- model: opened.prepared.model
1367
+ model: opened.prepared.model,
1368
+ startedAt: requestStartedAt
1335
1369
  }, opened.head);
1336
1370
  return;
1337
1371
  }
1338
1372
  lastKind = result.kind;
1373
+ lastAccountId = queueAccount;
1339
1374
  lastStatus = result.status;
1340
1375
  lastMessage = result.message;
1341
1376
  }
@@ -1345,11 +1380,13 @@ function createCodeArtsShim(options) {
1345
1380
  }
1346
1381
  if (lastKind === void 0) {
1347
1382
  const none = noAccountError(pool.list());
1383
+ usage?.noteFailure(none.kind);
1348
1384
  writeOpenAIError(res, none.status, none.kind, none.message);
1349
1385
  return;
1350
1386
  }
1351
1387
  const explanation = upstreamErrorMessage(lastMessage, lastStatus);
1352
1388
  const waited = queueWaitedMs >= 1e3 ? `(本地已排队重试 ${Math.round(queueWaitedMs / 1e3)} 秒)` : "";
1389
+ usage?.noteFailure(lastKind, lastAccountId);
1353
1390
  writeOpenAIError(res, KIND_STATUS[lastKind], lastKind, `${explanation}${waited}`);
1354
1391
  }
1355
1392
  /**
@@ -1379,6 +1416,8 @@ function createCodeArtsShim(options) {
1379
1416
  let buf = "";
1380
1417
  if (usage !== void 0 && attribution !== void 0) usage.noteRequest(attribution.accountId, attribution.model);
1381
1418
  let sawDone = false;
1419
+ /** 首个真正写出的帧记一次 TTFT;之后不再记。 */
1420
+ let firstFrame = true;
1382
1421
  const finish = () => {
1383
1422
  if (!res.writable || res.writableEnded) return;
1384
1423
  res.end(sawDone ? void 0 : "data: [DONE]\n\n");
@@ -1394,7 +1433,13 @@ function createCodeArtsShim(options) {
1394
1433
  if (frame.trim() === "data: [DONE]") sawDone = true;
1395
1434
  if (frame.includes("\"error\"")) logInStreamError(frame, attribution?.model ?? "");
1396
1435
  if (usage !== void 0 && attribution !== void 0 && frame.includes("\"usage\"")) recordFrameUsage(frame, attribution);
1397
- if (res.writable) res.write(frame + "\n\n");
1436
+ if (res.writable) {
1437
+ if (firstFrame) {
1438
+ firstFrame = false;
1439
+ if (usage !== void 0 && attribution?.startedAt !== void 0) usage.noteLatency(Date.now() - attribution.startedAt);
1440
+ }
1441
+ res.write(frame + "\n\n");
1442
+ }
1398
1443
  }
1399
1444
  }
1400
1445
  finish();
@@ -1471,6 +1516,13 @@ const CODEARTS_DOCK_PATH = "/api/codearts/dock";
1471
1516
  /** 最近出站请求的**形状**摘要(只有 role 名与计数,不含内容),排障用。 */
1472
1517
  const CODEARTS_DEBUG_PATH = "/api/codearts/debug";
1473
1518
  /**
1519
+ * GET /api/codearts/doctor — 把 CLI `doctor` 的静态体检搬进卡片。
1520
+ *
1521
+ * 只做本机可判定的检查(版本、账号、凭证有效期、目录、账本、pi-ai 兼容),
1522
+ * 不额外打上游 —— 卡片本来就每 60 秒刷一次状态,不该因为点了体检再等超时。
1523
+ */
1524
+ const CODEARTS_DOCTOR_PATH = "/api/codearts/doctor";
1525
+ /**
1474
1526
  * 由完整目录 + 已存 enabled 计算 selection。
1475
1527
  * - enabled 为空/未存 => 全部勾选(restricted=false)
1476
1528
  * - enabled 为非空列表 => 仅勾选其中的(restricted=true),缺的按未勾选
@@ -1487,6 +1539,8 @@ function selectionOf(models, enabled, writable) {
1487
1539
  blocked: m.blocked,
1488
1540
  ratio: m.ratio,
1489
1541
  supportsImages: m.supportsImages,
1542
+ contextWindow: m.contextWindow,
1543
+ maxTokens: m.maxTokens,
1490
1544
  ...m.error === void 0 ? {} : { error: m.error },
1491
1545
  enabled: restricted ? allowed.has(m.id) : true
1492
1546
  })),
@@ -1507,6 +1561,8 @@ function choicesOf(deps) {
1507
1561
  blocked: isBenefitBlocked(m.id),
1508
1562
  ...ratio === void 0 ? {} : { ratio },
1509
1563
  ...m.supportsImages === true ? { supportsImages: true } : {},
1564
+ ...m.contextWindowKnown === true ? { contextWindow: m.contextWindow } : {},
1565
+ ...m.maxTokens > 0 ? { maxTokens: m.maxTokens } : {},
1510
1566
  ...error === void 0 ? {} : { error }
1511
1567
  };
1512
1568
  });
@@ -1609,6 +1665,58 @@ async function probeIdentity(token) {
1609
1665
  clearTimeout(timer);
1610
1666
  }
1611
1667
  }
1668
+ /**
1669
+ * 落一次 OAuth 登录的凭证。
1670
+ *
1671
+ * 不带 `targetId` 是「添加账号」:同一账号更新,别的账号新增。
1672
+ *
1673
+ * 带 `targetId` 是「重新登录这个账号」:凭证写回原条目(保留 id、展示顺序和
1674
+ * 备注),而不是又长出一条 —— 以前卡片上只有「删除」,用户遇到凭证过期只能
1675
+ * 先把账号删掉再加回来,顺序和备注都丢。
1676
+ *
1677
+ * 上游只在 `fetchCallerIdentity` 成功时才给稳定 `userId`,拿不到身份就判断不了
1678
+ * 「是不是同一个人」。此时按用户意图替换(他点的就是这个账号的重登),并沿用
1679
+ * 旧条目的身份字段;只有两边 `userId` 都非空且不同,才判为登了另一个账号,
1680
+ * 退回「新增」,原账号原样不动。
1681
+ */
1682
+ async function settleLogin(deps, token, targetId) {
1683
+ const identity = await probeIdentity(token);
1684
+ const target = targetId === void 0 ? void 0 : await deps.store.credential(targetId);
1685
+ if (targetId !== void 0 && target !== void 0) {
1686
+ const known = typeof target.userId === "string" && target.userId !== "";
1687
+ if (!(identity !== null && known && target.userId !== identity.userId)) {
1688
+ const cred = credentialFromOAuth(token, target.label);
1689
+ cred.userId = cred.userId || target.userId;
1690
+ cred.userName = cred.userName || target.userName;
1691
+ cred.domainId = cred.domainId || target.domainId;
1692
+ const id = await deps.store.upsert(cred, targetId);
1693
+ deps.reviveAccount?.(id);
1694
+ if (identity !== null && identity.userId !== "") {
1695
+ if (identity.userId !== id) await deps.store.adoptIdentity(id, identity);
1696
+ deps.reviveAccount?.(identity.userId);
1697
+ return {
1698
+ id: identity.userId,
1699
+ replaced: true,
1700
+ conflict: false
1701
+ };
1702
+ }
1703
+ return {
1704
+ id,
1705
+ replaced: true,
1706
+ conflict: false
1707
+ };
1708
+ }
1709
+ }
1710
+ const id = await deps.store.upsertFromOAuth(token, void 0, identity ?? void 0);
1711
+ if (identity !== null && id !== identity.userId) await deps.store.adoptIdentity(id, identity);
1712
+ deps.reviveAccount?.(id);
1713
+ if (identity !== null && identity.userId !== id) deps.reviveAccount?.(identity.userId);
1714
+ return {
1715
+ id: identity?.userId ?? id,
1716
+ replaced: false,
1717
+ conflict: target !== void 0
1718
+ };
1719
+ }
1612
1720
  /** 账号还能用吗(没被标 stale、也没过期)。 */
1613
1721
  function accountUsable(summary) {
1614
1722
  if (summary.stale) return false;
@@ -1652,6 +1760,15 @@ function usageOf(deps) {
1652
1760
  days: snapshot?.days ?? 0,
1653
1761
  recentDays: snapshot?.recentDays ?? [],
1654
1762
  todayByHour: snapshot?.todayByHour ?? [],
1763
+ failuresToday: snapshot?.failuresToday ?? {
1764
+ total: 0,
1765
+ byKind: {}
1766
+ },
1767
+ failuresByAccountToday: snapshot?.failuresByAccountToday ?? {},
1768
+ latencyToday: snapshot?.latencyToday ?? {
1769
+ samples: 0,
1770
+ overflow: false
1771
+ },
1655
1772
  ...snapshot?.lastAt === void 0 ? {} : { lastAt: snapshot.lastAt },
1656
1773
  slots: slotsOf(deps)
1657
1774
  };
@@ -1736,6 +1853,89 @@ function statusHandler(deps) {
1736
1853
  };
1737
1854
  }
1738
1855
  /**
1856
+ * 本机体检:与 CLI 的 `doctor` 同一份判据,但只做不联网的检查。
1857
+ *
1858
+ * 这里的每一项都能在 1ms 内判定;需要网络的(凭证能不能续期、上游目录是否正常)
1859
+ * 已经在 /status 与 /refresh-models 里体现,重复做只会让「体检」按钮转圈。
1860
+ */
1861
+ async function doctorReport(deps) {
1862
+ const accounts = await deps.store.list();
1863
+ const models = deps.models();
1864
+ const checks = [];
1865
+ const hints = /* @__PURE__ */ new Set();
1866
+ checks.push({
1867
+ id: "runtime",
1868
+ ok: true,
1869
+ detail: `dsh-codearts ${CODEARTS_VERSION} · node ${process.version} · ${process.platform}`
1870
+ });
1871
+ checks.push({
1872
+ id: "accounts",
1873
+ ok: accounts.length > 0,
1874
+ detail: `${accounts.length}`
1875
+ });
1876
+ if (accounts.length === 0) hints.add("no-account");
1877
+ for (const account of accounts) {
1878
+ const problems = [];
1879
+ if (account.stale) problems.push("stale");
1880
+ if (!account.signable) problems.push("no-key");
1881
+ if (account.expiration !== void 0) {
1882
+ const expiresAt = Date.parse(account.expiration);
1883
+ if (!Number.isNaN(expiresAt) && expiresAt <= Date.now()) problems.push("expired");
1884
+ }
1885
+ checks.push({
1886
+ id: `account:${account.id}`,
1887
+ ok: problems.length === 0,
1888
+ detail: problems.length > 0 ? problems.join(",") : account.label ?? account.userName ?? account.expiration ?? "ok"
1889
+ });
1890
+ for (const problem of problems) hints.add(`account-${problem}`);
1891
+ }
1892
+ checks.push({
1893
+ id: "models",
1894
+ ok: models.length > 0,
1895
+ detail: `${models.length}`
1896
+ });
1897
+ if (models.length === 0) hints.add("empty-catalog");
1898
+ checks.push({
1899
+ id: "usage",
1900
+ ok: deps.usage !== void 0,
1901
+ detail: deps.usage === void 0 ? "tracker not wired" : "local ledger"
1902
+ });
1903
+ if (deps.usage === void 0) hints.add("usage-missing");
1904
+ if (process.env.DSH_HOME === void 0) hints.add("dsh-home-missing");
1905
+ const piAi = await probePiAiCompat();
1906
+ checks.push({
1907
+ id: "pi-ai",
1908
+ ok: piAi === void 0,
1909
+ ...piAi === void 0 ? {} : { detail: piAi }
1910
+ });
1911
+ if (piAi !== void 0) hints.add("pi-ai");
1912
+ return {
1913
+ ok: checks.every((check) => check.ok),
1914
+ version: CODEARTS_VERSION,
1915
+ node: process.version,
1916
+ checks,
1917
+ hints: [...hints]
1918
+ };
1919
+ }
1920
+ /** GET /api/codearts/doctor */
1921
+ function doctorHandler(deps) {
1922
+ return async (req, res) => {
1923
+ if (req.method !== "GET") {
1924
+ json(res, 405, { error: "method not allowed" });
1925
+ return;
1926
+ }
1927
+ if (!loopbackRequest(req)) {
1928
+ json(res, 403, { error: "request-not-trusted" });
1929
+ return;
1930
+ }
1931
+ try {
1932
+ json(res, 200, await doctorReport(deps));
1933
+ } catch (error) {
1934
+ json(res, 500, { error: safeMessage(error) });
1935
+ }
1936
+ };
1937
+ }
1938
+ /**
1739
1939
  * GET/POST /api/codearts/dock — 对话底部那一行的显隐开关读写。
1740
1940
  *
1741
1941
  * 和 enabled-models 同一套规矩:GET 给当前值 + writable,POST 只收布尔、
@@ -1905,13 +2105,14 @@ function tokenDeleteHandler(deps) {
1905
2105
  }
1906
2106
  /**
1907
2107
  * DELETE /api/codearts/accounts?id=<accountId> — 删掉一个账号。
2108
+ * POST /api/codearts/accounts — 改账号标记:`{ id, paused?, pinned? }`。
1908
2109
  *
1909
2110
  * id 必须显式给:多账号下「删掉当前那个」是歧义的。改状态的路由,所以和
1910
2111
  * /token 一样走 Host + Origin 双校验。
1911
2112
  */
1912
2113
  function accountsHandler(deps) {
1913
2114
  return async (req, res) => {
1914
- if (req.method !== "DELETE") {
2115
+ if (req.method !== "DELETE" && req.method !== "POST") {
1915
2116
  json(res, 405, { error: "method not allowed" });
1916
2117
  return;
1917
2118
  }
@@ -1919,12 +2120,47 @@ function accountsHandler(deps) {
1919
2120
  json(res, 403, { error: "request-not-trusted" });
1920
2121
  return;
1921
2122
  }
1922
- const id = (new URL(req.url ?? "", "http://localhost").searchParams.get("id") ?? "").trim();
1923
- if (id === "") {
1924
- json(res, 400, { error: "expected ?id=<accountId>" });
1925
- return;
1926
- }
2123
+ const url = new URL(req.url ?? "", "http://localhost");
1927
2124
  try {
2125
+ if (req.method === "POST") {
2126
+ const type = req.headers["content-type"];
2127
+ if (typeof type !== "string" || !type.trim().toLowerCase().startsWith("application/json")) {
2128
+ json(res, 415, { error: "content-type must be application/json" });
2129
+ return;
2130
+ }
2131
+ const body = JSON.parse(await readBody(req));
2132
+ const id = typeof body.id === "string" ? body.id.trim() : "";
2133
+ if (id === "") {
2134
+ json(res, 400, { error: "expected {\"id\": string}" });
2135
+ return;
2136
+ }
2137
+ let changed = false;
2138
+ if (typeof body.paused === "boolean") {
2139
+ if (!await deps.store.setPaused(id, body.paused)) {
2140
+ json(res, 404, { error: "unknown account" });
2141
+ return;
2142
+ }
2143
+ changed = true;
2144
+ }
2145
+ if (typeof body.pinned === "boolean") {
2146
+ if (!await deps.store.setPinned(id, body.pinned)) {
2147
+ json(res, 404, { error: "unknown account" });
2148
+ return;
2149
+ }
2150
+ changed = true;
2151
+ }
2152
+ if (!changed) {
2153
+ json(res, 400, { error: "expected paused and/or pinned boolean" });
2154
+ return;
2155
+ }
2156
+ json(res, 200, await buildStatus(deps));
2157
+ return;
2158
+ }
2159
+ const id = (url.searchParams.get("id") ?? "").trim();
2160
+ if (id === "") {
2161
+ json(res, 400, { error: "expected ?id=<accountId>" });
2162
+ return;
2163
+ }
1928
2164
  await deps.store.remove(id);
1929
2165
  json(res, 200, await buildStatus(deps));
1930
2166
  } catch (error) {
@@ -2169,6 +2405,8 @@ function registerCodeArtsStatusRoute(ctx, deps) {
2169
2405
  const activeLogins = /* @__PURE__ */ new Map();
2170
2406
  /** 已结束登录的终态,供迟到的轮询读取。被读一次即删。 */
2171
2407
  const recentLogins = /* @__PURE__ */ new Map();
2408
+ /** 本次登录是「重新登录」时,授权 URL → 目标账号 id。终态落库后即删。 */
2409
+ const reloginTargets = /* @__PURE__ */ new Map();
2172
2410
  const disposeStatus = ctx.webServer.register({
2173
2411
  kind: "exact",
2174
2412
  path: CODEARTS_STATUS_PATH,
@@ -2192,7 +2430,7 @@ function registerCodeArtsStatusRoute(ctx, deps) {
2192
2430
  const disposeLoginStart = ctx.webServer.register({
2193
2431
  kind: "exact",
2194
2432
  path: CODEARTS_LOGIN_PATH,
2195
- handler: loginStartHandler(deps, activeLogins, recentLogins)
2433
+ handler: loginStartHandler(deps, activeLogins, recentLogins, reloginTargets)
2196
2434
  });
2197
2435
  const disposeLoginStatus = ctx.webServer.register({
2198
2436
  kind: "exact",
@@ -2234,6 +2472,11 @@ function registerCodeArtsStatusRoute(ctx, deps) {
2234
2472
  path: CODEARTS_DEBUG_PATH,
2235
2473
  handler: debugHandler(deps)
2236
2474
  });
2475
+ const disposeDoctor = ctx.webServer.register({
2476
+ kind: "exact",
2477
+ path: CODEARTS_DOCTOR_PATH,
2478
+ handler: doctorHandler(deps)
2479
+ });
2237
2480
  return () => {
2238
2481
  disposeStatus();
2239
2482
  disposeModels();
@@ -2248,8 +2491,10 @@ function registerCodeArtsStatusRoute(ctx, deps) {
2248
2491
  disposeQueue();
2249
2492
  disposeDock();
2250
2493
  disposeDebug();
2494
+ disposeDoctor();
2251
2495
  for (const s of activeLogins.values()) s.close();
2252
2496
  activeLogins.clear();
2497
+ reloginTargets.clear();
2253
2498
  };
2254
2499
  }, "dsh-codearts: Web status route");
2255
2500
  }
@@ -2267,8 +2512,12 @@ function recordLoginOutcome(recent, url, outcome) {
2267
2512
  }
2268
2513
  recent.set(url, outcome);
2269
2514
  }
2270
- /** POST /api/codearts/login — 启动本地 OAuth 回调,返回授权 URL。 */
2271
- function loginStartHandler(deps, active, recent) {
2515
+ /**
2516
+ * POST /api/codearts/login — 启动本地 OAuth 回调,返回授权 URL。
2517
+ *
2518
+ * `?account=<id>` 表示「重新登录这个账号」(见 settleLogin);省略就是添加账号。
2519
+ */
2520
+ function loginStartHandler(deps, active, recent, targets) {
2272
2521
  return async (req, res) => {
2273
2522
  if (req.method !== "POST") {
2274
2523
  json(res, 405, { error: "method not allowed" });
@@ -2278,28 +2527,33 @@ function loginStartHandler(deps, active, recent) {
2278
2527
  json(res, 403, { error: "request-not-trusted" });
2279
2528
  return;
2280
2529
  }
2530
+ const targetId = (new URL(req.url ?? "", "http://localhost").searchParams.get("account") ?? "").trim();
2281
2531
  try {
2282
2532
  const session = await startLogin();
2283
2533
  active.set(session.url, session);
2534
+ if (targetId !== "") targets.set(session.url, targetId);
2284
2535
  session.promise.then(async (token) => {
2285
2536
  active.delete(session.url);
2537
+ const reloginId = targets.get(session.url);
2538
+ targets.delete(session.url);
2286
2539
  try {
2287
- const identity = await probeIdentity(token);
2288
- const id = await deps.store.upsertFromOAuth(token, void 0, identity ?? void 0);
2289
- if (identity !== null && id !== identity.userId) await deps.store.adoptIdentity(id, identity);
2290
- deps.reviveAccount?.(id);
2291
- if (identity !== null && identity.userId !== id) deps.reviveAccount?.(identity.userId);
2292
- recordLoginOutcome(recent, session.url, "done");
2540
+ const result = await settleLogin(deps, token, reloginId);
2541
+ recordLoginOutcome(recent, session.url, {
2542
+ status: "done",
2543
+ ...result
2544
+ });
2293
2545
  } catch {
2294
- recordLoginOutcome(recent, session.url, "error");
2546
+ recordLoginOutcome(recent, session.url, { status: "error" });
2295
2547
  }
2296
2548
  }, () => {
2297
2549
  active.delete(session.url);
2298
- recordLoginOutcome(recent, session.url, "error");
2550
+ targets.delete(session.url);
2551
+ recordLoginOutcome(recent, session.url, { status: "error" });
2299
2552
  });
2300
2553
  const timer = setTimeout(() => {
2301
2554
  session.close();
2302
- if (active.delete(session.url)) recordLoginOutcome(recent, session.url, "error");
2555
+ targets.delete(session.url);
2556
+ if (active.delete(session.url)) recordLoginOutcome(recent, session.url, { status: "error" });
2303
2557
  }, 3e5);
2304
2558
  session.promise.finally(() => clearTimeout(timer)).catch(() => {});
2305
2559
  json(res, 200, {
@@ -2330,7 +2584,7 @@ function loginPollHandler(_deps, active, recent) {
2330
2584
  const outcome = recent.get(key);
2331
2585
  if (outcome !== void 0) {
2332
2586
  recent.delete(key);
2333
- json(res, 200, { status: outcome });
2587
+ json(res, 200, { ...outcome });
2334
2588
  return;
2335
2589
  }
2336
2590
  json(res, 200, { status: "idle" });