sidekick-chat 0.2.9 → 0.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/README.md CHANGED
@@ -58,9 +58,18 @@ sidekick login --url http://47.96.64.66:18792 --token <设备令牌> # 直连
58
58
  ```
59
59
 
60
60
  - **网页密码**:就是网页版那个 `CORTICO_WEB_PASSWORD`。客户端拿它调站点的 `/api/auth/login`,
61
- 拿到 `cortico_session` Cookie 存起来,400 天不用重登。服务器地址默认 `https://hkv4.cznorth.cn/chat`。
61
+ 拿到 `cortico_session` Cookie 存起来,400 天不用重登。服务器地址默认 `https://hkv4.cznorth.cn/chat`(香港)。
62
62
  - **设备令牌**:部署 `.env` 里的 `SIDEKICK_DEVICE_TOKEN`,和手机 App 用的是同一个。
63
- 适合备用线路(杭州 `47.96.64.66:18792`)这种没有网页登录接口的直连地址。
63
+ 备用线路没有网页登录接口,要用令牌。
64
+
65
+ 默认线路顺序(主线路连不上或返回 502/503/504 才换,换过去 5 分钟后会先试回主线路):
66
+
67
+ 1. `https://hkv4.cznorth.cn/chat` — 香港主线路
68
+ 2. `https://kiro.ch66.top/sk-us/chat` — 美国直连(备用)
69
+ 3. `http://47.96.64.66:18792/chat` — 阿里云 frps → 香港 OpenResty → 美国(备用)
70
+
71
+ 用 `--url` 自己指定地址时不会自动加备用线路;要备用就再给 `--alt`(可重复,或逗号分隔),
72
+ 也可以写进 `~/.sidekick/config.json` 的 `alts` 数组。`SIDEKICK_ALT` / `SIDEKICK_ALTS` 环境变量同样认。
64
73
 
65
74
  `sidekick logout` 清掉本机登录态。Cookie 失效时会提示重新 `sidekick login`。
66
75
 
@@ -99,6 +108,7 @@ sidekick --device 家里的电脑 "…" # 换一个署名(默认「终端」)
99
108
  | 变量 | 作用 |
100
109
  |---|---|
101
110
  | `SIDEKICK_URL` | 服务器地址,如 `https://hkv4.cznorth.cn/chat` |
111
+ | `SIDEKICK_ALT` / `SIDEKICK_ALTS` | 备用线路,逗号分隔;主线路连不上时按顺序换 |
102
112
  | `SIDEKICK_TOKEN` | 设备令牌(Bearer) |
103
113
  | `SIDEKICK_PASSWORD` | 网页登录密码(只在 `sidekick login` 时用,不落盘) |
104
114
  | `SIDEKICK_COOKIE` | 直接给 `cortico_session` 的值 |
@@ -137,3 +147,5 @@ cd cli && node --test
137
147
 
138
148
  `test/fake-world.mjs` 起了个假的 chat World(实现上面五个接口),所以鉴权失败、发送、推流补收、
139
149
  `sidekick ask` 端到端这些都能脱网验证。
150
+
151
+ 网络断开或发送超时会保留当前消息并自动重试,确认送达后才继续后续发送;重试共用消息编号,服务端按编号去重。重试期间请勿重复输入同一句话。退出客户端会停止重试,尚未送达的消息不会跨进程保存。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-chat",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Sidekick 终端客户端:在终端里和秘书对话",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
package/src/api.mjs CHANGED
@@ -5,23 +5,42 @@
5
5
  * `cortico_session` Cookie(和网页版同一个 `CORTICO_WEB_PASSWORD`)。接口本身见
6
6
  * packages/world-chat/src/world.ts 的 `startHub`。
7
7
  */
8
+ import { randomUUID } from 'node:crypto';
8
9
  import { authOrigin } from './config.mjs';
9
10
 
10
11
  /** 鉴权没过:登录态失效或令牌不对,调用方据此提示重新登录。 */
11
12
  export class AuthError extends Error {}
12
13
 
13
- /** 连不上(没拿到响应):只有这种错才换备用线路,4xx / 5xx 不换。 */
14
+ /** 连不上(没拿到响应):只有这种错才换备用线路,4xx 不换。 */
14
15
  export class NetworkError extends Error {}
15
16
 
17
+ export class HttpError extends Error {
18
+ constructor(status, message) { super(message); this.status = status; }
19
+ }
20
+
21
+ /** 网关错误通常是隧道断了、上游没起来,换一条线路可能就好了。 */
22
+ function routeFailed(err) {
23
+ return err instanceof NetworkError || (err instanceof HttpError && [502, 503, 504].includes(err.status));
24
+ }
25
+
16
26
  const REQUEST_TIMEOUT_MS = 20_000;
27
+ /** 换到备用线路后,过这么久再试回主线路。 */
28
+ const PRIMARY_RETRY_MS = 5 * 60_000;
17
29
 
18
30
  export class SidekickApi {
19
- /** `alt` 是备用线路(如杭州 `http://47.96.64.66:18792`),只有主线路连不上才用。 */
20
- constructor({ url, alt, token, cookie }) {
31
+ /**
32
+ * `alts` 是备用线路列表(美国直连、阿里→香港→美国),按顺序在连不上时换;
33
+ * `alt` 是旧版的单条写法,仍认。
34
+ */
35
+ constructor({ url, alt, alts, token, cookie, now = Date.now, primaryRetryMs = PRIMARY_RETRY_MS }) {
21
36
  this.primary = stripSlashes(String(url || ''));
22
- this.alt = stripSlashes(String(alt || ''));
37
+ const list = Array.isArray(alts) ? alts : alts !== undefined ? [alts] : alt !== undefined ? [alt] : [];
38
+ this.alts = [...new Set(list.map((b) => stripSlashes(String(b || ''))).filter((b) => b && b !== this.primary))];
23
39
  /** 当前用着哪条:切到备用线路后就不再每次先撞一次主线路。 */
24
40
  this.current = this.primary;
41
+ this.switchedAt = 0;
42
+ this.now = now;
43
+ this.primaryRetryMs = primaryRetryMs;
25
44
  this.token = token || '';
26
45
  this.cookie = cookie || '';
27
46
  }
@@ -37,20 +56,32 @@ export class SidekickApi {
37
56
  return h;
38
57
  }
39
58
 
40
- /** 先试当前线路,连不上再试备用线路(只有拿不到响应才算连不上)。 */
59
+ /**
60
+ * 当前线路优先;换到备用线路超过 5 分钟后,下一次请求先试回主线路。
61
+ * 返回的列表里不再包含已经试过的那条当前线路。
62
+ */
63
+ routeOrder() {
64
+ const all = [this.primary, ...this.alts].filter(Boolean);
65
+ if (this.current === this.primary) return all;
66
+ const backups = all.filter((b) => b !== this.primary);
67
+ if (this.now() - this.switchedAt >= this.primaryRetryMs) return [this.primary, ...backups];
68
+ // 还没到回头试主线路的时候:当前这条优先,其余备用在后面,主线路垫底。
69
+ return [this.current, ...backups.filter((b) => b !== this.current), this.primary];
70
+ }
71
+
72
+ /** 先试当前线路,连不上再按顺序试备用线路。4xx 是请求本身的问题,不换。 */
41
73
  async attempt(run) {
42
- const bases = [this.current === this.alt ? this.primary : this.current];
43
- if (this.alt && this.alt !== bases[0]) bases.push(this.alt);
44
74
  let last;
45
- for (const base of bases) {
75
+ for (const base of this.routeOrder()) {
46
76
  try {
47
77
  const out = await run(base);
48
78
  this.current = base;
79
+ this.switchedAt = base === this.primary ? 0 : this.now();
49
80
  return out;
50
81
  } catch (err) {
51
- if (!(err instanceof NetworkError)) {
82
+ if (!routeFailed(err)) {
52
83
  // 备用线路上没有网页登录接口:提示改用令牌,别让人以为密码错了。
53
- if (err instanceof AuthError && base === this.alt && this.cookie) {
84
+ if (err instanceof AuthError && base !== this.primary && this.cookie) {
54
85
  throw new AuthError('备用线路不认网页登录,改成 sidekick login --token <设备令牌>');
55
86
  }
56
87
  throw err;
@@ -76,9 +107,15 @@ export class SidekickApi {
76
107
  if (signal?.aborted) throw signal.reason;
77
108
  throw new NetworkError(networkHint(url, err));
78
109
  }
79
- const data = await res.json().catch(() => ({}));
110
+ let data;
111
+ try { data = await res.json(); }
112
+ catch (err) {
113
+ if (signal?.aborted) throw signal.reason;
114
+ if (!res.ok) data = {};
115
+ else throw new NetworkError(networkHint(url, err));
116
+ }
80
117
  if (res.status === 401) throw new AuthError(data.error || '登录态已失效');
81
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
118
+ if (!res.ok) throw new HttpError(res.status, data.error || `HTTP ${res.status}`);
82
119
  return data;
83
120
  });
84
121
  }
@@ -92,8 +129,20 @@ export class SidekickApi {
92
129
  return this.request('GET', `v1/team/history?conv=${encodeURIComponent(conv)}&limit=${limit}`);
93
130
  }
94
131
 
95
- send({ conv, text, device, images }) {
96
- return this.request('POST', 'v1/team/send', { conv, text, device, images });
132
+ async send({ conv, text, device, images }, { signal, onRetry } = {}) {
133
+ const body = { conv, text, device, images, client_id: randomUUID() };
134
+ let delay = 1000;
135
+ for (;;) {
136
+ signal?.throwIfAborted();
137
+ try { return await this.request('POST', 'v1/team/send', body, { signal }); }
138
+ catch (err) {
139
+ if (!(err instanceof NetworkError) && !(err instanceof HttpError && [408, 502, 503, 504].includes(err.status))) throw err;
140
+ onRetry?.(err);
141
+ signal?.throwIfAborted();
142
+ await sleep(delay, signal);
143
+ delay = Math.min(delay * 2, 10000);
144
+ }
145
+ }
97
146
  }
98
147
 
99
148
  read(conv, seq) {
package/src/config.mjs CHANGED
@@ -7,8 +7,17 @@ import { homedir } from 'node:os';
7
7
  import { dirname, join } from 'node:path';
8
8
 
9
9
  export const DEFAULT_URL = 'https://hkv4.cznorth.cn/chat';
10
- /** 主线路连不上时自动换的备用线路(杭州阿里云 frps 把 chat World 的 18792 开在公网)。 */
11
- export const DEFAULT_ALT = 'http://47.96.64.66:18792';
10
+ /**
11
+ * 主线路(香港)连不上时按顺序换的备用线路:
12
+ * 1. 美国直连(kiro.ch66.top 的证书在美机上,`/sk-us` 前缀转到各 World);
13
+ * 2. 阿里云 frps → 香港 OpenResty → 美国(和主线路同一个后端,只是入口在杭州)。
14
+ */
15
+ export const DEFAULT_ALTS = [
16
+ 'https://kiro.ch66.top/sk-us/chat',
17
+ 'http://47.96.64.66:18792/chat',
18
+ ];
19
+ /** 旧配置里的单条备用线路;仍然是「阿里云 → 美国」,现在中间也经香港。 */
20
+ export const DEFAULT_ALT = DEFAULT_ALTS[1];
12
21
 
13
22
  export function configPath() {
14
23
  return process.env.SIDEKICK_CONFIG || join(homedir(), '.sidekick', 'config.json');
@@ -48,13 +57,17 @@ export function clearConfig() {
48
57
  export function resolveSettings(flags = {}) {
49
58
  const saved = readConfig();
50
59
  const url = String(flags.url || process.env.SIDEKICK_URL || saved.url || DEFAULT_URL).trim();
51
- const alt = String(flags.alt || process.env.SIDEKICK_ALT || saved.alt || defaultAltFor(url)).trim();
60
+ const rawAlts = flags.alts ?? flags.alt ?? process.env.SIDEKICK_ALTS ?? process.env.SIDEKICK_ALT
61
+ ?? upgradeSavedAlts(saved) ?? defaultAltsFor(url);
62
+ const alts = normalizeAlts(rawAlts).map(stripTrailingSlash);
52
63
  const token = String(flags.token || process.env.SIDEKICK_TOKEN || saved.token || '').trim();
53
64
  const cookie = String(process.env.SIDEKICK_COOKIE || saved.cookie || '').trim();
54
65
  const device = String(flags.device || process.env.SIDEKICK_DEVICE || saved.device || '终端').trim();
55
66
  return {
56
67
  url: stripTrailingSlash(url),
57
- alt: stripTrailingSlash(alt),
68
+ /** 旧代码只认单条备用线路,保留第一个。 */
69
+ alt: alts[0] || '',
70
+ alts,
58
71
  token,
59
72
  cookie,
60
73
  device,
@@ -64,13 +77,34 @@ export function resolveSettings(flags = {}) {
64
77
  };
65
78
  }
66
79
 
80
+ /** 旧版本登录时会自动把单条杭州备用线路写进 `alt`;读到它就升级成新的两条默认线路。 */
81
+ export function upgradeSavedAlts(saved) {
82
+ if (Array.isArray(saved.alts)) return saved.alts;
83
+ if (!saved.alt) return undefined;
84
+ const old = stripTrailingSlash(String(saved.alt));
85
+ if (old === 'http://47.96.64.66:18792' || old === 'http://47.96.64.66:18792/chat') return [...DEFAULT_ALTS];
86
+ return [saved.alt];
87
+ }
88
+
89
+ /** `--alt` 可重复,也可写成逗号/空格/换行分隔;数组要原样保留。 */
90
+ export function normalizeAlts(value) {
91
+ if (value === undefined || value === null) return [];
92
+ const list = Array.isArray(value) ? value : String(value).split(/[,\s]+/);
93
+ return [...new Set(list.map((v) => String(v ?? '').trim()).filter(Boolean))];
94
+ }
95
+
67
96
  export function stripTrailingSlash(url) {
68
97
  return url.replace(/\/+$/, '');
69
98
  }
70
99
 
71
100
  /** 用默认的主线路时自动带上已知的备用线路;自己指定 `--url` 就只走那一条。 */
101
+ export function defaultAltsFor(url) {
102
+ return stripTrailingSlash(url) === DEFAULT_URL ? [...DEFAULT_ALTS] : [];
103
+ }
104
+
105
+ /** 兼容旧调用:只要第一条备用线路。 */
72
106
  export function defaultAltFor(url) {
73
- return stripTrailingSlash(url) === DEFAULT_URL ? DEFAULT_ALT : '';
107
+ return defaultAltsFor(url)[0] || '';
74
108
  }
75
109
 
76
110
  /** WebUI 的登录接口在站点根上,不在 `/chat/` 这类前缀下面。 */
package/src/main.mjs CHANGED
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { AuthError, SidekickApi, loginWithPassword } from './api.mjs';
12
12
  import {
13
- DEFAULT_ALT,
13
+ DEFAULT_ALTS,
14
14
  DEFAULT_URL,
15
15
  clearConfig,
16
16
  configPath,
@@ -54,7 +54,8 @@ const HELP = `Sidekick 终端客户端 ${PACKAGE_VERSION}
54
54
 
55
55
  参数:
56
56
  --url <地址> 服务器地址,默认 ${DEFAULT_URL}
57
- --alt <地址> 备用线路,主线路连不上时自动换,默认 ${DEFAULT_ALT}
57
+ --alt <地址> 备用线路,主线路连不上时按顺序换;可重复或逗号分隔
58
+ 默认:${DEFAULT_ALTS.join(' , ')}
58
59
  --token <令牌> 设备令牌(部署 .env 的 SIDEKICK_DEVICE_TOKEN),跳过密码登录
59
60
  --password <密码> 网页登录密码(不给就在终端里问)
60
61
  --conv <会话> 指定会话:id、名字或 /conv 列表里的序号,默认和秘书的私聊
@@ -68,13 +69,13 @@ const HELP = `Sidekick 终端客户端 ${PACKAGE_VERSION}
68
69
 
69
70
  进交互后可用 /help 看命令,/conv 列会话,/to 切换,Ctrl-C 退出。
70
71
 
71
- 环境变量(优先于配置文件):SIDEKICK_URL、SIDEKICK_ALT、SIDEKICK_TOKEN、SIDEKICK_PASSWORD、SIDEKICK_DEVICE、SIDEKICK_COOKIE。
72
+ 环境变量(优先于配置文件):SIDEKICK_URL、SIDEKICK_ALT / SIDEKICK_ALTS、SIDEKICK_TOKEN、SIDEKICK_PASSWORD、SIDEKICK_DEVICE、SIDEKICK_COOKIE。
72
73
  配置放在 ${configPath()}(权限 600)。`;
73
74
 
74
75
  /** 命令行参数解析:node 自带的 parseArgs,不引第三方依赖。 */
75
76
  function parseFlags(argv) {
76
77
  const flags = { positional: [] };
77
- const takesValue = new Set(['url', 'alt', 'token', 'password', 'conv', 'device', 'wait']);
78
+ const takesValue = new Set(['url', 'alt', 'alts', 'token', 'password', 'conv', 'device', 'wait']);
78
79
  for (let i = 0; i < argv.length; i += 1) {
79
80
  const arg = argv[i];
80
81
  if (arg === '--') {
@@ -107,7 +108,9 @@ function parseFlags(argv) {
107
108
  if (!takesValue.has(name)) throw new Error(`不认识的参数:--${name}`);
108
109
  const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i];
109
110
  if (value === undefined) throw new Error(`--${name} 缺一个值`);
110
- flags[name] = value;
111
+ // --alt 可重复:攒成列表交给 resolveSettings。
112
+ if (name === 'alt') (flags.alts ??= []).push(value);
113
+ else flags[name] = value;
111
114
  continue;
112
115
  }
113
116
  flags.positional.push(arg);
@@ -143,10 +146,11 @@ async function loginCommand(flags) {
143
146
  cookie = await loginWithPassword(url, password);
144
147
  }
145
148
 
146
- const api = new SidekickApi({ url, alt: current.alt, token, cookie });
149
+ const api = new SidekickApi({ url, alts: current.alts, token, cookie });
147
150
  const state = await api.state();
148
151
  const primary = state.bots.find((b) => b.primary);
149
- const cfg = { ...readConfig(), url, alt: current.alt, device: current.device || '终端', savedAt: new Date().toISOString() };
152
+ const cfg = { ...readConfig(), url, alts: current.alts, device: current.device || '终端', savedAt: new Date().toISOString() };
153
+ delete cfg.alt;
150
154
  if (token) {
151
155
  cfg.token = token;
152
156
  delete cfg.cookie;
@@ -553,7 +557,17 @@ async function startChat(flags) {
553
557
 
554
558
  async function sendText(text) {
555
559
  const images = pendingImages.slice();
556
- const res = await api.send({ conv: convId, text, device: settings.device, ...(images.length ? { images } : {}) });
560
+ let warned = false;
561
+ const res = await api.send({ conv: convId, text, device: settings.device, ...(images.length ? { images } : {}) }, {
562
+ signal: ctl.signal,
563
+ onRetry: () => {
564
+ if (!warned) out('消息尚未确认送达,已保留并自动重试,请勿重复发送(退出客户端会停止重试)。');
565
+ warned = true;
566
+ },
567
+ }).catch((err) => {
568
+ if (!closed) out(`未发送的消息:${text}`);
569
+ throw err;
570
+ });
557
571
  pendingImages = [];
558
572
  ui?.attachments(0);
559
573
  latest = Math.max(latest, res.seq);
@@ -570,6 +584,7 @@ async function startChat(flags) {
570
584
  const text = line.trim();
571
585
  if (!text) return;
572
586
  markPresent();
587
+ if (text === '/quit' || text === '/exit') { shutdown(0); return; }
573
588
  queue.push(() => (text.startsWith('/') ? handleSlash(text) : sendText(text)));
574
589
  void drain();
575
590
  });
package/src/quota.mjs CHANGED
@@ -75,7 +75,12 @@ export function createQuotaReader(settings, { api = new SidekickApi(settings), a
75
75
  opencode: () => api.request('GET', 'v1/opencode/quota', undefined, { signal: ctl.signal }),
76
76
  codex: () => api.request('GET', 'v1/codex/status', undefined, { signal: ctl.signal }),
77
77
  claude: () => {
78
- ccApi ??= new SidekickApi({ ...settings, url: ccwebBase(settings.url), alt: ccwebBase(settings.alt) });
78
+ if (!ccApi) {
79
+ // 每条线路都换成对应的 ccweb 入口;推不出的(自定义地址)跳过,别让额度查询整个失败。
80
+ const bases = [settings.url, ...(settings.alts ?? (settings.alt ? [settings.alt] : []))]
81
+ .flatMap((base) => { try { return [ccwebBase(base)]; } catch { return []; } });
82
+ ccApi = new SidekickApi({ ...settings, url: bases[0], alts: bases.slice(1) });
83
+ }
79
84
  return ccApi.request('GET', 'v1/agents', undefined, { signal: ctl.signal });
80
85
  },
81
86
  };