sidekick-chat 0.0.0-stage → 0.2.2
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/LICENSE +75 -0
- package/README.md +127 -2
- package/bin/sidekick.mjs +7 -0
- package/package.json +38 -6
- package/src/api.mjs +236 -0
- package/src/config.mjs +84 -0
- package/src/format.mjs +94 -0
- package/src/main.mjs +578 -0
- package/src/prompt.mjs +66 -0
- package/src/quota.mjs +107 -0
- package/src/tui.mjs +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Required Notice: Copyright (c) 2026 Cznorth (https://github.com/Cznorth/sidekick)
|
|
2
|
+
|
|
3
|
+
# PolyForm Noncommercial License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncommercial Purposes
|
|
34
|
+
|
|
35
|
+
Any noncommercial purpose is a permitted purpose.
|
|
36
|
+
|
|
37
|
+
## Personal Uses
|
|
38
|
+
|
|
39
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
40
|
+
|
|
41
|
+
## Noncommercial Organizations
|
|
42
|
+
|
|
43
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
44
|
+
|
|
45
|
+
## Fair Use
|
|
46
|
+
|
|
47
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
48
|
+
|
|
49
|
+
## No Other Rights
|
|
50
|
+
|
|
51
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
52
|
+
|
|
53
|
+
## Patent Defense
|
|
54
|
+
|
|
55
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
56
|
+
|
|
57
|
+
## Violations
|
|
58
|
+
|
|
59
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
60
|
+
|
|
61
|
+
## No Liability
|
|
62
|
+
|
|
63
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
64
|
+
|
|
65
|
+
## Definitions
|
|
66
|
+
|
|
67
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
68
|
+
|
|
69
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
70
|
+
|
|
71
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
72
|
+
|
|
73
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
74
|
+
|
|
75
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
CHANGED
|
@@ -1,3 +1,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sidekick 终端客户端
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在终端里和秘书说话。它只是 `chat` World 的又一个客户端,和手机 App、网页版共用同一批会话、未读和回复,
|
|
4
|
+
不新增任何后端接口——所以手机上收到的、终端里发的,是同一份对话。
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
$ npx sidekick-chat
|
|
8
|
+
Sidekick · 秘书 https://hkv4.cznorth.cn/chat
|
|
9
|
+
会话:秘书 · /help 看命令,/conv 列会话,Ctrl-C 退出
|
|
10
|
+
────────────────────────────
|
|
11
|
+
[11-03 13:39] 秘书 codex/gpt-5.5
|
|
12
|
+
已经把 frp 备用入口补给执行器了。
|
|
13
|
+
你 › 明天几点提醒我看牙
|
|
14
|
+
[13:44] 我
|
|
15
|
+
明天几点提醒我看牙
|
|
16
|
+
[13:44] 秘书 codex/gpt-5.5
|
|
17
|
+
记成明天 9:30,提前 10 分钟提醒。
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx sidekick-chat # 不装直接用
|
|
24
|
+
npm i -g sidekick-chat # 或者装到全局,命令就是 sidekick
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
仓库里直接用(还没发包时):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node cli/bin/sidekick.mjs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
0.2.2 起,输入框上方 HUD 显示 OpenCode、Codex、Claude WSL 和 Claude Docker 的剩余额度,每分钟刷新。显示采样时间,刷新失败保留旧值并标注;无数据时显示未知。`/quota` 可立即刷新并查看本地日期时间标注的重置时间,纯文本交互也支持。额度沿用 App 的接口,凭据只用于访问同一服务器;不需要在 CLI 配置模型 API key。
|
|
34
|
+
|
|
35
|
+
0.2.1 起,交互界面实时显示服务器的流式回复草稿,支持生成中的 Markdown;正式消息到达后替换草稿,不重复显示。切换会话、断线、清屏或服务端撤回会清除临时草稿。草稿不落盘,重连只补正式消息。`--plain`、管道、一次性 `ask` 和 `--json` 只输出正式消息。
|
|
36
|
+
|
|
37
|
+
需要 Node 22.19 以上。交互界面使用与 Pi agent 相同的 `@earendil-works/pi-tui`,支持 Markdown、中文换行、多行粘贴、输入历史和窗口缩放。
|
|
38
|
+
|
|
39
|
+
Enter 发送,Shift+Enter 换行(终端需支持;也可用 Ctrl+J),↑/↓ 翻输入历史。回复和忙碌状态更新时保留输入草稿与光标。
|
|
40
|
+
|
|
41
|
+
老终端可用 `sidekick --plain`:不上色、不重画输入行。管道和 `TERM=dumb` 自动使用纯文本;`--no-color` / `NO_COLOR` 只关闭颜色。一次性 `ask` 和 `--json` 保持文本/JSON 输出。
|
|
42
|
+
|
|
43
|
+
## 登录
|
|
44
|
+
|
|
45
|
+
凭据存在 `~/.sidekick/config.json`(权限 600),两种方式任选:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
sidekick login # 问地址和网页密码,换一个登录 Cookie
|
|
49
|
+
sidekick login --url http://47.96.64.66:18792 --token <设备令牌> # 直连 chat World
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- **网页密码**:就是网页版那个 `CORTICO_WEB_PASSWORD`。客户端拿它调站点的 `/api/auth/login`,
|
|
53
|
+
拿到 `cortico_session` Cookie 存起来,400 天不用重登。服务器地址默认 `https://hkv4.cznorth.cn/chat`。
|
|
54
|
+
- **设备令牌**:部署 `.env` 里的 `SIDEKICK_DEVICE_TOKEN`,和手机 App 用的是同一个。
|
|
55
|
+
适合备用线路(杭州 `47.96.64.66:18792`)这种没有网页登录接口的直连地址。
|
|
56
|
+
|
|
57
|
+
`sidekick logout` 清掉本机登录态。Cookie 失效时会提示重新 `sidekick login`。
|
|
58
|
+
|
|
59
|
+
## 用法
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sidekick # 进交互,一行一句
|
|
63
|
+
sidekick "今天有什么安排" # 问一句,回复打印完就退出(适合脚本/管道)
|
|
64
|
+
sidekick ask --wait 20 "…" # 同上,收到第一条后最多再等 20 秒后续消息
|
|
65
|
+
sidekick --conv 收发室 # 指定会话:名字、id 或 /conv 里的序号
|
|
66
|
+
sidekick --json "…" # 每条消息输出一行 JSON
|
|
67
|
+
sidekick --device 家里的电脑 "…" # 换一个署名(默认「终端」)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
交互里的命令:
|
|
71
|
+
|
|
72
|
+
| 命令 | 作用 |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `/conv` | 列出会话(带未读和最后一条摘要) |
|
|
75
|
+
| `/to <序号\|名字>` | 切换会话 |
|
|
76
|
+
| `/history [n]` | 重印最近 n 条,默认 20 |
|
|
77
|
+
| `/who` | 名册:谁在线、谁在忙 |
|
|
78
|
+
| `/quota` | 刷新额度,查看剩余比例、采样和重置时间 |
|
|
79
|
+
| `/clear` | 清屏 |
|
|
80
|
+
| `/quit` | 退出(也可以 Ctrl-C) |
|
|
81
|
+
|
|
82
|
+
其它会话来消息时只在终端里提一句,`/to` 切过去看。其它设备上聊的、手机上聊的,这里都能看到历史的最近 30 条。
|
|
83
|
+
|
|
84
|
+
## 环境变量
|
|
85
|
+
|
|
86
|
+
参数优先于环境变量,环境变量优先于配置文件:
|
|
87
|
+
|
|
88
|
+
| 变量 | 作用 |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `SIDEKICK_URL` | 服务器地址,如 `https://hkv4.cznorth.cn/chat` |
|
|
91
|
+
| `SIDEKICK_TOKEN` | 设备令牌(Bearer) |
|
|
92
|
+
| `SIDEKICK_PASSWORD` | 网页登录密码(只在 `sidekick login` 时用,不落盘) |
|
|
93
|
+
| `SIDEKICK_COOKIE` | 直接给 `cortico_session` 的值 |
|
|
94
|
+
| `SIDEKICK_DEVICE` | 这台机器的署名 |
|
|
95
|
+
| `SIDEKICK_CONFIG` | 配置文件路径,默认 `~/.sidekick/config.json` |
|
|
96
|
+
| `NO_COLOR` | 设了就不上色 |
|
|
97
|
+
|
|
98
|
+
## 怎么实现的
|
|
99
|
+
|
|
100
|
+
只用了 `chat` World 已经开给手机 App 和网页的那几个接口(`packages/world-chat/src/world.ts` 的 `startHub`):
|
|
101
|
+
|
|
102
|
+
| 接口 | 用途 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `GET /v1/team/state` | 主 Bot、名册、会话列表 |
|
|
105
|
+
| `GET /v1/team/history?conv=&limit=` | 一个会话的历史 |
|
|
106
|
+
| `POST /v1/team/send` | 发一句 |
|
|
107
|
+
| `POST /v1/team/read` | 标记已读 |
|
|
108
|
+
| `GET /v1/team/stream?after=` | SSE 推流:消息、正在输入、连接状态 |
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
bin/sidekick.mjs 入口,把参数交给 src/main.mjs
|
|
112
|
+
src/main.mjs login / logout / ask / 交互三种流程
|
|
113
|
+
src/api.mjs 接口调用与 SSE 解析,断线按 1s→30s 退避重连,重连时带 seq 补收
|
|
114
|
+
src/config.mjs ~/.sidekick/config.json 读写,参数 > 环境变量 > 配置文件的优先级
|
|
115
|
+
src/format.mjs 纯文本消息排版(纯函数)
|
|
116
|
+
src/tui.mjs Pi TUI:Markdown、编辑器、忙碌状态和差分渲染
|
|
117
|
+
src/prompt.mjs 提问与遮掉回显的密码输入
|
|
118
|
+
test/ node:test,`npm test`;有一个假 World 顶替服务器,不需要联网
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 测试
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
cd cli && node --test
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`test/fake-world.mjs` 起了个假的 chat World(实现上面五个接口),所以鉴权失败、发送、推流补收、
|
|
128
|
+
`sidekick ask` 端到端这些都能脱网验证。
|
package/bin/sidekick.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "sidekick-chat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "sidekick-chat",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Sidekick 终端客户端:在终端里和秘书对话",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.19.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"sidekick": "bin/sidekick.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"sidekick",
|
|
24
|
+
"cli",
|
|
25
|
+
"chat",
|
|
26
|
+
"assistant"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Cznorth/sidekick-chat.git"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@earendil-works/pi-tui": "1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@xterm/headless": "5.5.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 和 chat World 的 HTTP 接口打交道:鉴权、状态、历史、发送、SSE 推流。
|
|
3
|
+
*
|
|
4
|
+
* 只认两条路:设备令牌(Bearer,和手机 App 同一个 `SIDEKICK_DEVICE_TOKEN`),或网页登录拿到的
|
|
5
|
+
* `cortico_session` Cookie(和网页版同一个 `CORTICO_WEB_PASSWORD`)。接口本身见
|
|
6
|
+
* packages/world-chat/src/world.ts 的 `startHub`。
|
|
7
|
+
*/
|
|
8
|
+
import { authOrigin } from './config.mjs';
|
|
9
|
+
|
|
10
|
+
/** 鉴权没过:登录态失效或令牌不对,调用方据此提示重新登录。 */
|
|
11
|
+
export class AuthError extends Error {}
|
|
12
|
+
|
|
13
|
+
/** 连不上(没拿到响应):只有这种错才换备用线路,4xx / 5xx 不换。 */
|
|
14
|
+
export class NetworkError extends Error {}
|
|
15
|
+
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
17
|
+
|
|
18
|
+
export class SidekickApi {
|
|
19
|
+
/** `alt` 是备用线路(如杭州 `http://47.96.64.66:18792`),只有主线路连不上才用。 */
|
|
20
|
+
constructor({ url, alt, token, cookie }) {
|
|
21
|
+
this.primary = stripSlashes(String(url || ''));
|
|
22
|
+
this.alt = stripSlashes(String(alt || ''));
|
|
23
|
+
/** 当前用着哪条:切到备用线路后就不再每次先撞一次主线路。 */
|
|
24
|
+
this.current = this.primary;
|
|
25
|
+
this.token = token || '';
|
|
26
|
+
this.cookie = cookie || '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get base() {
|
|
30
|
+
return this.current;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get headers() {
|
|
34
|
+
const h = { 'content-type': 'application/json' };
|
|
35
|
+
if (this.token) h.authorization = `Bearer ${this.token}`;
|
|
36
|
+
if (this.cookie) h.cookie = `cortico_session=${this.cookie}`;
|
|
37
|
+
return h;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 先试当前线路,连不上再试备用线路(只有拿不到响应才算连不上)。 */
|
|
41
|
+
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
|
+
let last;
|
|
45
|
+
for (const base of bases) {
|
|
46
|
+
try {
|
|
47
|
+
const out = await run(base);
|
|
48
|
+
this.current = base;
|
|
49
|
+
return out;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (!(err instanceof NetworkError)) {
|
|
52
|
+
// 备用线路上没有网页登录接口:提示改用令牌,别让人以为密码错了。
|
|
53
|
+
if (err instanceof AuthError && base === this.alt && this.cookie) {
|
|
54
|
+
throw new AuthError('备用线路不认网页登录,改成 sidekick login --token <设备令牌>');
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
last = err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw last;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async request(method, path, body, { signal } = {}) {
|
|
65
|
+
return this.attempt(async (base) => {
|
|
66
|
+
const url = `${base}/${path.replace(/^\/+/, '')}`;
|
|
67
|
+
let res;
|
|
68
|
+
try {
|
|
69
|
+
res = await fetch(url, {
|
|
70
|
+
method,
|
|
71
|
+
headers: this.headers,
|
|
72
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
73
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (signal?.aborted) throw signal.reason;
|
|
77
|
+
throw new NetworkError(networkHint(url, err));
|
|
78
|
+
}
|
|
79
|
+
const data = await res.json().catch(() => ({}));
|
|
80
|
+
if (res.status === 401) throw new AuthError(data.error || '登录态已失效');
|
|
81
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
82
|
+
return data;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 拿一次服务器全貌:主 Bot、名册、会话列表和全局最新的 seq。 */
|
|
87
|
+
state() {
|
|
88
|
+
return this.request('GET', 'v1/team/state');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
history(conv, limit = 60) {
|
|
92
|
+
return this.request('GET', `v1/team/history?conv=${encodeURIComponent(conv)}&limit=${limit}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
send({ conv, text, device }) {
|
|
96
|
+
return this.request('POST', 'v1/team/send', { conv, text, device });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
read(conv, seq) {
|
|
100
|
+
return this.request('POST', 'v1/team/read', { conv, seq });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
imageUrl(name) {
|
|
104
|
+
return `${this.base}/v1/team/image?name=${encodeURIComponent(name)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 订阅推流,断了按退避重连,重连时带上最后的 seq 把漏掉的补齐。
|
|
109
|
+
* `onEvent(event, payload)` 收到 hello / state / message / draft / thinking / busy / read。
|
|
110
|
+
* 返回的 promise 只在 abort 时结束。
|
|
111
|
+
*/
|
|
112
|
+
async stream({ after = 0, device, signal, onEvent, onStatus }) {
|
|
113
|
+
let seq = after;
|
|
114
|
+
let delay = 1000;
|
|
115
|
+
while (!signal.aborted) {
|
|
116
|
+
try {
|
|
117
|
+
await this.openStream({ after: seq, device, signal, onEvent: (event, payload) => {
|
|
118
|
+
if (event === 'message' && typeof payload?.seq === 'number') seq = Math.max(seq, payload.seq);
|
|
119
|
+
if (event === 'state' && typeof payload?.latest === 'number') seq = Math.max(seq, payload.latest);
|
|
120
|
+
onEvent(event, payload);
|
|
121
|
+
}, onStatus });
|
|
122
|
+
delay = 1000;
|
|
123
|
+
onStatus?.('closed');
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (signal.aborted) return;
|
|
126
|
+
if (err instanceof AuthError) throw err;
|
|
127
|
+
onStatus?.('error', err);
|
|
128
|
+
}
|
|
129
|
+
// 断线重连:1s、2s、4s…最多 30s。
|
|
130
|
+
await sleep(delay, signal);
|
|
131
|
+
delay = Math.min(30_000, delay * 2);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async openStream({ after, device, signal, onEvent, onStatus }) {
|
|
136
|
+
const res = await this.attempt(async (base) => {
|
|
137
|
+
const url = `${base}/v1/team/stream?after=${after}&device=${encodeURIComponent(device || '终端')}`;
|
|
138
|
+
const r = await fetch(url, {
|
|
139
|
+
headers: { ...this.headers, accept: 'text/event-stream' },
|
|
140
|
+
signal,
|
|
141
|
+
}).catch((err) => {
|
|
142
|
+
throw new NetworkError(networkHint(url, err));
|
|
143
|
+
});
|
|
144
|
+
// 4xx / 5xx 不是网络问题,不换线路重试。
|
|
145
|
+
if (r.status === 401) throw new AuthError('登录态已失效');
|
|
146
|
+
if (!r.ok) throw new Error(`推流 HTTP ${r.status}`);
|
|
147
|
+
return r;
|
|
148
|
+
});
|
|
149
|
+
if (!res.body) throw new Error('推流没有响应体');
|
|
150
|
+
onStatus?.('open');
|
|
151
|
+
const decoder = new TextDecoder();
|
|
152
|
+
const reader = res.body.getReader();
|
|
153
|
+
let buf = '';
|
|
154
|
+
try {
|
|
155
|
+
for (;;) {
|
|
156
|
+
const { value, done } = await reader.read();
|
|
157
|
+
if (done) return;
|
|
158
|
+
buf += decoder.decode(value, { stream: true });
|
|
159
|
+
let cut;
|
|
160
|
+
while ((cut = buf.indexOf('\n\n')) >= 0) {
|
|
161
|
+
const block = buf.slice(0, cut);
|
|
162
|
+
buf = buf.slice(cut + 2);
|
|
163
|
+
const frame = parseFrame(block);
|
|
164
|
+
if (frame) onEvent(frame.event, frame.data);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
reader.cancel().catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** SSE 一块:`event: x` + 若干 `data:` 行;只有注释(心跳)或 `retry:` 时返回 null。 */
|
|
174
|
+
export function parseFrame(block) {
|
|
175
|
+
let event = 'message';
|
|
176
|
+
let data = '';
|
|
177
|
+
for (const line of block.split('\n')) {
|
|
178
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
179
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
180
|
+
}
|
|
181
|
+
if (!data) return null;
|
|
182
|
+
try {
|
|
183
|
+
return { event, data: JSON.parse(data) };
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 用网页登录密码换登录态 Cookie;WebUI 不在这个地址上时抛错说明改用令牌。 */
|
|
190
|
+
export async function loginWithPassword(url, password) {
|
|
191
|
+
const endpoint = `${authOrigin(url)}/api/auth/login`;
|
|
192
|
+
let res;
|
|
193
|
+
try {
|
|
194
|
+
res = await fetch(endpoint, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'content-type': 'application/json' },
|
|
197
|
+
body: JSON.stringify({ password }),
|
|
198
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
199
|
+
});
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new Error(`连不上 ${endpoint}:${err instanceof Error ? err.message : String(err)}`);
|
|
202
|
+
}
|
|
203
|
+
if (res.status === 401) throw new AuthError('密码不对');
|
|
204
|
+
if (res.status === 404) throw new Error(`${authOrigin(url)} 上没有网页登录接口,请改用 --token(设备令牌)`);
|
|
205
|
+
if (!res.ok) throw new Error(`登录失败:HTTP ${res.status}`);
|
|
206
|
+
const raw = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie') ?? ''];
|
|
207
|
+
for (const line of raw) {
|
|
208
|
+
const m = /(?:^|;\s*)cortico_session=([^;]+)/.exec(line || '');
|
|
209
|
+
if (m) return decodeURIComponent(m[1]);
|
|
210
|
+
}
|
|
211
|
+
throw new Error('服务器没发登录 Cookie,请改用 --token(设备令牌)');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** fetch 在网络层失败时只给一句 `fetch failed`,这里把地址和常见原因一起说出来。 */
|
|
215
|
+
export function networkHint(url, err) {
|
|
216
|
+
if (err instanceof Error && err.name === 'TimeoutError') return `连不上 ${url}(超时)`;
|
|
217
|
+
const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : '';
|
|
218
|
+
return `连不上 ${url}${cause ? `(${cause})` : ''}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function stripSlashes(url) {
|
|
222
|
+
return url.replace(/\/+$/, '');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 等 ms 毫秒;signal 中止时提前返回。 */
|
|
226
|
+
export function sleep(ms, signal) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const timer = setTimeout(done, ms);
|
|
229
|
+
function done() {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
signal?.removeEventListener?.('abort', done);
|
|
232
|
+
resolve();
|
|
233
|
+
}
|
|
234
|
+
signal?.addEventListener?.('abort', done, { once: true });
|
|
235
|
+
});
|
|
236
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 终端客户端的本地配置:`~/.sidekick/config.json`(权限 600)。
|
|
3
|
+
* 只存服务器地址、登录凭据(设备令牌或网页登录 Cookie)和上次用的会话,不存密码明文。
|
|
4
|
+
*/
|
|
5
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
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';
|
|
12
|
+
|
|
13
|
+
export function configPath() {
|
|
14
|
+
return process.env.SIDEKICK_CONFIG || join(homedir(), '.sidekick', 'config.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readConfig() {
|
|
18
|
+
const file = configPath();
|
|
19
|
+
try {
|
|
20
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
return typeof raw === 'object' && raw ? raw : {};
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeConfig(cfg) {
|
|
28
|
+
const file = configPath();
|
|
29
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
30
|
+
writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
// Windows 上 mode 只影响只读位,这里补一次尽力而为的收紧。
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(file, 0o600);
|
|
34
|
+
} catch {
|
|
35
|
+
/* 某些文件系统不支持,忽略 */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearConfig() {
|
|
40
|
+
const file = configPath();
|
|
41
|
+
if (existsSync(file)) writeFileSync(file, '{}\n', { mode: 0o600 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 把命令行参数、环境变量和配置文件合成一份可用设置。优先级:参数 > 环境变量 > 配置文件。
|
|
46
|
+
* 设备令牌和 Cookie 二者取其一;都没有时返回的 `loggedIn` 为 false,由调用方引导登录。
|
|
47
|
+
*/
|
|
48
|
+
export function resolveSettings(flags = {}) {
|
|
49
|
+
const saved = readConfig();
|
|
50
|
+
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();
|
|
52
|
+
const token = String(flags.token || process.env.SIDEKICK_TOKEN || saved.token || '').trim();
|
|
53
|
+
const cookie = String(process.env.SIDEKICK_COOKIE || saved.cookie || '').trim();
|
|
54
|
+
const device = String(flags.device || process.env.SIDEKICK_DEVICE || saved.device || '终端').trim();
|
|
55
|
+
return {
|
|
56
|
+
url: stripTrailingSlash(url),
|
|
57
|
+
alt: stripTrailingSlash(alt),
|
|
58
|
+
token,
|
|
59
|
+
cookie,
|
|
60
|
+
device,
|
|
61
|
+
conv: flags.conv || saved.conv || '',
|
|
62
|
+
loggedIn: Boolean(token || cookie),
|
|
63
|
+
saved,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function stripTrailingSlash(url) {
|
|
68
|
+
return url.replace(/\/+$/, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 用默认的主线路时自动带上已知的备用线路;自己指定 `--url` 就只走那一条。 */
|
|
72
|
+
export function defaultAltFor(url) {
|
|
73
|
+
return stripTrailingSlash(url) === DEFAULT_URL ? DEFAULT_ALT : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** WebUI 的登录接口在站点根上,不在 `/chat/` 这类前缀下面。 */
|
|
77
|
+
export function authOrigin(url) {
|
|
78
|
+
try {
|
|
79
|
+
const u = new URL(url);
|
|
80
|
+
return `${u.protocol}//${u.host}`;
|
|
81
|
+
} catch {
|
|
82
|
+
return stripTrailingSlash(url);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/format.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把消息排版成终端里的一行行文字。纯函数,不碰网络和终端状态,好测。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const ANSI = {
|
|
6
|
+
reset: '\u001b[0m',
|
|
7
|
+
dim: '\u001b[2m',
|
|
8
|
+
bold: '\u001b[1m',
|
|
9
|
+
cyan: '\u001b[36m',
|
|
10
|
+
green: '\u001b[32m',
|
|
11
|
+
yellow: '\u001b[33m',
|
|
12
|
+
red: '\u001b[31m',
|
|
13
|
+
gray: '\u001b[90m',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** stdout 是终端、且没设 NO_COLOR 时才上色。 */
|
|
17
|
+
export function colorsEnabled(stream = process.stdout) {
|
|
18
|
+
if (process.env.NO_COLOR) return false;
|
|
19
|
+
if (process.env.FORCE_COLOR) return true;
|
|
20
|
+
return Boolean(stream?.isTTY);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function paint(enabled, code, text) {
|
|
24
|
+
return enabled ? `${ANSI[code]}${text}${ANSI.reset}` : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
28
|
+
|
|
29
|
+
export function clockTime(ts) {
|
|
30
|
+
const d = new Date(ts);
|
|
31
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function dayLabel(ts) {
|
|
35
|
+
const d = new Date(ts);
|
|
36
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 消息的发送者显示名:`user` 是「我」,`system` 是「系统」,其余查名册,查不到就用 id。 */
|
|
40
|
+
export function senderName(message, bots = []) {
|
|
41
|
+
if (message.from === 'user') return '我';
|
|
42
|
+
if (message.from === 'system') return '系统';
|
|
43
|
+
return bots.find((b) => b.id === message.from)?.name || message.from;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 一条消息排成多行:头一行是时间、名字和模型,正文缩进两格。
|
|
48
|
+
* `options.imageUrl` 给了就把图片链接也写出来。
|
|
49
|
+
*/
|
|
50
|
+
export function formatMessage(message, { bots = [], color = false, imageUrl } = {}) {
|
|
51
|
+
const who = senderName(message, bots);
|
|
52
|
+
const mine = message.from === 'user';
|
|
53
|
+
const model = message.model ? paint(color, 'gray', message.model) : '';
|
|
54
|
+
const head = [
|
|
55
|
+
paint(color, 'gray', `[${clockTime(message.ts)}]`),
|
|
56
|
+
paint(color, mine ? 'green' : 'cyan', who),
|
|
57
|
+
model,
|
|
58
|
+
]
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join(' ');
|
|
61
|
+
const lines = [head];
|
|
62
|
+
if (message.reply_to) {
|
|
63
|
+
const quote = `↳ 回复 ${message.reply_to.name || message.reply_to.from}:${message.reply_to.text}`;
|
|
64
|
+
lines.push(` ${paint(color, 'gray', quote)}`);
|
|
65
|
+
}
|
|
66
|
+
const body = String(message.text ?? '');
|
|
67
|
+
for (const line of body.split('\n')) lines.push(` ${line}`);
|
|
68
|
+
for (const name of message.images ?? []) {
|
|
69
|
+
const link = imageUrl ? ` ${imageUrl(name)}` : '';
|
|
70
|
+
lines.push(` ${paint(color, 'gray', `[图片]${link}`)}`);
|
|
71
|
+
}
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 会话列表里的一行:序号、未读数、标题、最后一条摘要。 */
|
|
76
|
+
export function formatConversation(conv, index, { bots = [], current = '' } = {}) {
|
|
77
|
+
const mark = conv.id === current ? '*' : ' ';
|
|
78
|
+
const unread = conv.unread > 0 ? ` (${conv.unread})` : '';
|
|
79
|
+
const last = conv.last ? ` — ${senderName(conv.last, bots)}:${oneLine(conv.last.text, 40)}` : '';
|
|
80
|
+
return `${mark}${String(index).padStart(2)}. ${conv.title}${unread}${last}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 去掉换行、截断到 n 个字,给列表和引用用。 */
|
|
84
|
+
export function oneLine(text, n = 60) {
|
|
85
|
+
const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
86
|
+
return flat.length > n ? `${flat.slice(0, n)}…` : flat;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 一条消息的纯文本版(一次性模式打印用,便于管道)。 */
|
|
90
|
+
export function plainMessage(message, bots = []) {
|
|
91
|
+
return `${senderName(message, bots)}: ${String(message.text ?? '')}`.trimEnd();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { ANSI };
|