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 +14 -2
- package/package.json +1 -1
- package/src/api.mjs +63 -14
- package/src/config.mjs +39 -5
- package/src/main.mjs +23 -8
- package/src/quota.mjs +6 -1
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
|
-
|
|
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
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
|
|
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
|
-
/**
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
82
|
+
if (!routeFailed(err)) {
|
|
52
83
|
// 备用线路上没有网页登录接口:提示改用令牌,别让人以为密码错了。
|
|
53
|
-
if (err instanceof AuthError && base
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 <地址>
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|