sidekick-chat 0.0.0-stage → 0.2.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.
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,123 @@
1
- # Temporary Holding Version
1
+ # Sidekick 终端客户端
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ 需要 Node 22.19 以上。交互界面使用与 Pi agent 相同的 `@earendil-works/pi-tui`,支持 Markdown、中文换行、多行粘贴、输入历史和窗口缩放。
34
+
35
+ Enter 发送,Shift+Enter 换行(终端需支持;也可用 Ctrl+J),↑/↓ 翻输入历史。回复和忙碌状态更新时保留输入草稿与光标。
36
+
37
+ 老终端可用 `sidekick --plain`:不上色、不重画输入行。管道和 `TERM=dumb` 自动使用纯文本;`--no-color` / `NO_COLOR` 只关闭颜色。一次性 `ask` 和 `--json` 保持文本/JSON 输出。
38
+
39
+ ## 登录
40
+
41
+ 凭据存在 `~/.sidekick/config.json`(权限 600),两种方式任选:
42
+
43
+ ```bash
44
+ sidekick login # 问地址和网页密码,换一个登录 Cookie
45
+ sidekick login --url http://47.96.64.66:18792 --token <设备令牌> # 直连 chat World
46
+ ```
47
+
48
+ - **网页密码**:就是网页版那个 `CORTICO_WEB_PASSWORD`。客户端拿它调站点的 `/api/auth/login`,
49
+ 拿到 `cortico_session` Cookie 存起来,400 天不用重登。服务器地址默认 `https://hkv4.cznorth.cn/chat`。
50
+ - **设备令牌**:部署 `.env` 里的 `SIDEKICK_DEVICE_TOKEN`,和手机 App 用的是同一个。
51
+ 适合备用线路(杭州 `47.96.64.66:18792`)这种没有网页登录接口的直连地址。
52
+
53
+ `sidekick logout` 清掉本机登录态。Cookie 失效时会提示重新 `sidekick login`。
54
+
55
+ ## 用法
56
+
57
+ ```bash
58
+ sidekick # 进交互,一行一句
59
+ sidekick "今天有什么安排" # 问一句,回复打印完就退出(适合脚本/管道)
60
+ sidekick ask --wait 20 "…" # 同上,收到第一条后最多再等 20 秒后续消息
61
+ sidekick --conv 收发室 # 指定会话:名字、id 或 /conv 里的序号
62
+ sidekick --json "…" # 每条消息输出一行 JSON
63
+ sidekick --device 家里的电脑 "…" # 换一个署名(默认「终端」)
64
+ ```
65
+
66
+ 交互里的命令:
67
+
68
+ | 命令 | 作用 |
69
+ |---|---|
70
+ | `/conv` | 列出会话(带未读和最后一条摘要) |
71
+ | `/to <序号\|名字>` | 切换会话 |
72
+ | `/history [n]` | 重印最近 n 条,默认 20 |
73
+ | `/who` | 名册:谁在线、谁在忙 |
74
+ | `/clear` | 清屏 |
75
+ | `/quit` | 退出(也可以 Ctrl-C) |
76
+
77
+ 其它会话来消息时只在终端里提一句,`/to` 切过去看。其它设备上聊的、手机上聊的,这里都能看到历史的最近 30 条。
78
+
79
+ ## 环境变量
80
+
81
+ 参数优先于环境变量,环境变量优先于配置文件:
82
+
83
+ | 变量 | 作用 |
84
+ |---|---|
85
+ | `SIDEKICK_URL` | 服务器地址,如 `https://hkv4.cznorth.cn/chat` |
86
+ | `SIDEKICK_TOKEN` | 设备令牌(Bearer) |
87
+ | `SIDEKICK_PASSWORD` | 网页登录密码(只在 `sidekick login` 时用,不落盘) |
88
+ | `SIDEKICK_COOKIE` | 直接给 `cortico_session` 的值 |
89
+ | `SIDEKICK_DEVICE` | 这台机器的署名 |
90
+ | `SIDEKICK_CONFIG` | 配置文件路径,默认 `~/.sidekick/config.json` |
91
+ | `NO_COLOR` | 设了就不上色 |
92
+
93
+ ## 怎么实现的
94
+
95
+ 只用了 `chat` World 已经开给手机 App 和网页的那几个接口(`packages/world-chat/src/world.ts` 的 `startHub`):
96
+
97
+ | 接口 | 用途 |
98
+ |---|---|
99
+ | `GET /v1/team/state` | 主 Bot、名册、会话列表 |
100
+ | `GET /v1/team/history?conv=&limit=` | 一个会话的历史 |
101
+ | `POST /v1/team/send` | 发一句 |
102
+ | `POST /v1/team/read` | 标记已读 |
103
+ | `GET /v1/team/stream?after=` | SSE 推流:消息、正在输入、连接状态 |
104
+
105
+ ```
106
+ bin/sidekick.mjs 入口,把参数交给 src/main.mjs
107
+ src/main.mjs login / logout / ask / 交互三种流程
108
+ src/api.mjs 接口调用与 SSE 解析,断线按 1s→30s 退避重连,重连时带 seq 补收
109
+ src/config.mjs ~/.sidekick/config.json 读写,参数 > 环境变量 > 配置文件的优先级
110
+ src/format.mjs 纯文本消息排版(纯函数)
111
+ src/tui.mjs Pi TUI:Markdown、编辑器、忙碌状态和差分渲染
112
+ src/prompt.mjs 提问与遮掉回显的密码输入
113
+ test/ node:test,`npm test`;有一个假 World 顶替服务器,不需要联网
114
+ ```
115
+
116
+ ## 测试
117
+
118
+ ```bash
119
+ cd cli && node --test
120
+ ```
121
+
122
+ `test/fake-world.mjs` 起了个假的 chat World(实现上面五个接口),所以鉴权失败、发送、推流补收、
123
+ `sidekick ask` 端到端这些都能脱网验证。
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx sidekick-chat` / `npm i -g sidekick-chat` 之后的入口:只是把参数交给 src/main.mjs。
4
+ */
5
+ import { run } from '../src/main.mjs';
6
+
7
+ await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
- {
2
- "name": "sidekick-chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "sidekick-chat",
3
+ "version": "0.2.0",
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,235 @@
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) {
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: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
74
+ });
75
+ } catch (err) {
76
+ throw new NetworkError(networkHint(url, err));
77
+ }
78
+ const data = await res.json().catch(() => ({}));
79
+ if (res.status === 401) throw new AuthError(data.error || '登录态已失效');
80
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
81
+ return data;
82
+ });
83
+ }
84
+
85
+ /** 拿一次服务器全貌:主 Bot、名册、会话列表和全局最新的 seq。 */
86
+ state() {
87
+ return this.request('GET', 'v1/team/state');
88
+ }
89
+
90
+ history(conv, limit = 60) {
91
+ return this.request('GET', `v1/team/history?conv=${encodeURIComponent(conv)}&limit=${limit}`);
92
+ }
93
+
94
+ send({ conv, text, device }) {
95
+ return this.request('POST', 'v1/team/send', { conv, text, device });
96
+ }
97
+
98
+ read(conv, seq) {
99
+ return this.request('POST', 'v1/team/read', { conv, seq });
100
+ }
101
+
102
+ imageUrl(name) {
103
+ return `${this.base}/v1/team/image?name=${encodeURIComponent(name)}`;
104
+ }
105
+
106
+ /**
107
+ * 订阅推流,断了按退避重连,重连时带上最后的 seq 把漏掉的补齐。
108
+ * `onEvent(event, payload)` 收到 hello / state / message / draft / thinking / busy / read。
109
+ * 返回的 promise 只在 abort 时结束。
110
+ */
111
+ async stream({ after = 0, device, signal, onEvent, onStatus }) {
112
+ let seq = after;
113
+ let delay = 1000;
114
+ while (!signal.aborted) {
115
+ try {
116
+ await this.openStream({ after: seq, device, signal, onEvent: (event, payload) => {
117
+ if (event === 'message' && typeof payload?.seq === 'number') seq = Math.max(seq, payload.seq);
118
+ if (event === 'state' && typeof payload?.latest === 'number') seq = Math.max(seq, payload.latest);
119
+ onEvent(event, payload);
120
+ }, onStatus });
121
+ delay = 1000;
122
+ onStatus?.('closed');
123
+ } catch (err) {
124
+ if (signal.aborted) return;
125
+ if (err instanceof AuthError) throw err;
126
+ onStatus?.('error', err);
127
+ }
128
+ // 断线重连:1s、2s、4s…最多 30s。
129
+ await sleep(delay, signal);
130
+ delay = Math.min(30_000, delay * 2);
131
+ }
132
+ }
133
+
134
+ async openStream({ after, device, signal, onEvent, onStatus }) {
135
+ const res = await this.attempt(async (base) => {
136
+ const url = `${base}/v1/team/stream?after=${after}&device=${encodeURIComponent(device || '终端')}`;
137
+ const r = await fetch(url, {
138
+ headers: { ...this.headers, accept: 'text/event-stream' },
139
+ signal,
140
+ }).catch((err) => {
141
+ throw new NetworkError(networkHint(url, err));
142
+ });
143
+ // 4xx / 5xx 不是网络问题,不换线路重试。
144
+ if (r.status === 401) throw new AuthError('登录态已失效');
145
+ if (!r.ok) throw new Error(`推流 HTTP ${r.status}`);
146
+ return r;
147
+ });
148
+ if (!res.body) throw new Error('推流没有响应体');
149
+ onStatus?.('open');
150
+ const decoder = new TextDecoder();
151
+ const reader = res.body.getReader();
152
+ let buf = '';
153
+ try {
154
+ for (;;) {
155
+ const { value, done } = await reader.read();
156
+ if (done) return;
157
+ buf += decoder.decode(value, { stream: true });
158
+ let cut;
159
+ while ((cut = buf.indexOf('\n\n')) >= 0) {
160
+ const block = buf.slice(0, cut);
161
+ buf = buf.slice(cut + 2);
162
+ const frame = parseFrame(block);
163
+ if (frame) onEvent(frame.event, frame.data);
164
+ }
165
+ }
166
+ } finally {
167
+ reader.cancel().catch(() => {});
168
+ }
169
+ }
170
+ }
171
+
172
+ /** SSE 一块:`event: x` + 若干 `data:` 行;只有注释(心跳)或 `retry:` 时返回 null。 */
173
+ export function parseFrame(block) {
174
+ let event = 'message';
175
+ let data = '';
176
+ for (const line of block.split('\n')) {
177
+ if (line.startsWith('event:')) event = line.slice(6).trim();
178
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
179
+ }
180
+ if (!data) return null;
181
+ try {
182
+ return { event, data: JSON.parse(data) };
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /** 用网页登录密码换登录态 Cookie;WebUI 不在这个地址上时抛错说明改用令牌。 */
189
+ export async function loginWithPassword(url, password) {
190
+ const endpoint = `${authOrigin(url)}/api/auth/login`;
191
+ let res;
192
+ try {
193
+ res = await fetch(endpoint, {
194
+ method: 'POST',
195
+ headers: { 'content-type': 'application/json' },
196
+ body: JSON.stringify({ password }),
197
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
198
+ });
199
+ } catch (err) {
200
+ throw new Error(`连不上 ${endpoint}:${err instanceof Error ? err.message : String(err)}`);
201
+ }
202
+ if (res.status === 401) throw new AuthError('密码不对');
203
+ if (res.status === 404) throw new Error(`${authOrigin(url)} 上没有网页登录接口,请改用 --token(设备令牌)`);
204
+ if (!res.ok) throw new Error(`登录失败:HTTP ${res.status}`);
205
+ const raw = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie') ?? ''];
206
+ for (const line of raw) {
207
+ const m = /(?:^|;\s*)cortico_session=([^;]+)/.exec(line || '');
208
+ if (m) return decodeURIComponent(m[1]);
209
+ }
210
+ throw new Error('服务器没发登录 Cookie,请改用 --token(设备令牌)');
211
+ }
212
+
213
+ /** fetch 在网络层失败时只给一句 `fetch failed`,这里把地址和常见原因一起说出来。 */
214
+ export function networkHint(url, err) {
215
+ if (err instanceof Error && err.name === 'TimeoutError') return `连不上 ${url}(超时)`;
216
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : '';
217
+ return `连不上 ${url}${cause ? `(${cause})` : ''}`;
218
+ }
219
+
220
+ function stripSlashes(url) {
221
+ return url.replace(/\/+$/, '');
222
+ }
223
+
224
+ /** 等 ms 毫秒;signal 中止时提前返回。 */
225
+ export function sleep(ms, signal) {
226
+ return new Promise((resolve) => {
227
+ const timer = setTimeout(done, ms);
228
+ function done() {
229
+ clearTimeout(timer);
230
+ signal?.removeEventListener?.('abort', done);
231
+ resolve();
232
+ }
233
+ signal?.addEventListener?.('abort', done, { once: true });
234
+ });
235
+ }
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 };
package/src/main.mjs ADDED
@@ -0,0 +1,560 @@
1
+ /**
2
+ * 终端客户端的主流程:`sidekick` 进交互对话,`sidekick login` 存凭据,`sidekick ask "…"` 一次性问一句。
3
+ *
4
+ * 它只是 chat World 的又一个客户端,和手机 App、网页版共用同一份会话和未读,
5
+ * 不新增任何后端接口(见 packages/world-chat/src/world.ts 的 `startHub`)。
6
+ */
7
+ import { createInterface } from 'node:readline';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { AuthError, SidekickApi, loginWithPassword } from './api.mjs';
11
+ import {
12
+ DEFAULT_ALT,
13
+ DEFAULT_URL,
14
+ clearConfig,
15
+ configPath,
16
+ readConfig,
17
+ resolveSettings,
18
+ stripTrailingSlash,
19
+ writeConfig,
20
+ } from './config.mjs';
21
+ import {
22
+ dayLabel,
23
+ formatConversation,
24
+ formatMessage,
25
+ colorsEnabled,
26
+ oneLine,
27
+ paint,
28
+ plainMessage,
29
+ } from './format.mjs';
30
+ import { ask, askPassword } from './prompt.mjs';
31
+
32
+ const PACKAGE_VERSION = (() => {
33
+ try {
34
+ const file = fileURLToPath(new URL('../package.json', import.meta.url));
35
+ return JSON.parse(readFileSync(file, 'utf8')).version;
36
+ } catch {
37
+ return '0.0.0';
38
+ }
39
+ })();
40
+
41
+ const HELP = `Sidekick 终端客户端 ${PACKAGE_VERSION}
42
+
43
+ 用法:
44
+ sidekick 和秘书对话(进去后输入一行回车发一条)
45
+ sidekick "今天有什么安排" 问一句,等回复打印完就退出
46
+ sidekick ask --wait 20 "…" 同上,收到第一条后最多再等 20 秒后续消息
47
+ sidekick login 登录(网页密码或设备令牌)
48
+ sidekick logout 删掉本机保存的登录态
49
+ sidekick help 这份说明
50
+
51
+ 参数:
52
+ --url <地址> 服务器地址,默认 ${DEFAULT_URL}
53
+ --alt <地址> 备用线路,主线路连不上时自动换,默认 ${DEFAULT_ALT}
54
+ --token <令牌> 设备令牌(部署 .env 的 SIDEKICK_DEVICE_TOKEN),跳过密码登录
55
+ --password <密码> 网页登录密码(不给就在终端里问)
56
+ --conv <会话> 指定会话:id、名字或 /conv 列表里的序号,默认和秘书的私聊
57
+ --device <名字> 这台机器的署名,默认「终端」
58
+ --wait <秒> 一次性模式:收到第一条后再等多久算说完,默认 6
59
+ --json 一次性模式:每条消息输出一行 JSON
60
+ --no-color 不上色
61
+ --plain 纯文本:不上色、不重画输入行(老终端/管道/日志里用)
62
+ -h, --help 这份说明
63
+ -v, --version 版本
64
+
65
+ 进交互后可用 /help 看命令,/conv 列会话,/to 切换,Ctrl-C 退出。
66
+
67
+ 环境变量(优先于配置文件):SIDEKICK_URL、SIDEKICK_ALT、SIDEKICK_TOKEN、SIDEKICK_PASSWORD、SIDEKICK_DEVICE、SIDEKICK_COOKIE。
68
+ 配置放在 ${configPath()}(权限 600)。`;
69
+
70
+ /** 命令行参数解析:node 自带的 parseArgs,不引第三方依赖。 */
71
+ function parseFlags(argv) {
72
+ const flags = { positional: [] };
73
+ const takesValue = new Set(['url', 'alt', 'token', 'password', 'conv', 'device', 'wait']);
74
+ for (let i = 0; i < argv.length; i += 1) {
75
+ const arg = argv[i];
76
+ if (arg === '--') {
77
+ flags.positional.push(...argv.slice(i + 1));
78
+ break;
79
+ }
80
+ if (arg === '-h' || arg === '--help') {
81
+ flags.help = true;
82
+ continue;
83
+ }
84
+ if (arg === '-v' || arg === '--version') {
85
+ flags.version = true;
86
+ continue;
87
+ }
88
+ if (arg === '--json') {
89
+ flags.json = true;
90
+ continue;
91
+ }
92
+ if (arg === '--no-color') {
93
+ flags.noColor = true;
94
+ continue;
95
+ }
96
+ if (arg === '--plain') {
97
+ flags.plain = true;
98
+ continue;
99
+ }
100
+ if (arg.startsWith('--')) {
101
+ const eq = arg.indexOf('=');
102
+ const name = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
103
+ if (!takesValue.has(name)) throw new Error(`不认识的参数:--${name}`);
104
+ const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i];
105
+ if (value === undefined) throw new Error(`--${name} 缺一个值`);
106
+ flags[name] = value;
107
+ continue;
108
+ }
109
+ flags.positional.push(arg);
110
+ }
111
+ return flags;
112
+ }
113
+
114
+ const stdout = (text) => process.stdout.write(`${text}\n`);
115
+
116
+ function fail(message) {
117
+ process.stderr.write(`${message}\n`);
118
+ process.exitCode = 1;
119
+ }
120
+
121
+ /** 登录:有令牌就用令牌,否则拿网页密码换 Cookie,最后都用 `/v1/team/state` 验一次。 */
122
+ async function loginCommand(flags) {
123
+ const current = resolveSettings(flags);
124
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
125
+ let url = stripTrailingSlash(String(flags.url || current.url || DEFAULT_URL));
126
+ if (interactive && !flags.url) {
127
+ url = stripTrailingSlash(await ask(`服务器地址 [${current.url}]:`, { fallback: current.url }));
128
+ }
129
+
130
+ let token = String(flags.token || '').trim();
131
+ let cookie = '';
132
+ if (!token) {
133
+ let password = String(flags.password || process.env.SIDEKICK_PASSWORD || '');
134
+ if (!password) {
135
+ if (!interactive) throw new Error('没有终端可问密码:请用 --password 或 --token');
136
+ password = await askPassword('网页登录密码(和网页版同一个 CORTICO_WEB_PASSWORD):');
137
+ if (!password) throw new Error('没输入密码');
138
+ }
139
+ cookie = await loginWithPassword(url, password);
140
+ }
141
+
142
+ const api = new SidekickApi({ url, alt: current.alt, token, cookie });
143
+ const state = await api.state();
144
+ const primary = state.bots.find((b) => b.primary);
145
+ const cfg = { ...readConfig(), url, alt: current.alt, device: current.device || '终端', savedAt: new Date().toISOString() };
146
+ if (token) {
147
+ cfg.token = token;
148
+ delete cfg.cookie;
149
+ } else {
150
+ cfg.cookie = cookie;
151
+ delete cfg.token;
152
+ }
153
+ writeConfig(cfg);
154
+ stdout(`登录成功:${url}`);
155
+ stdout(`主 Bot:${primary?.name || state.primary};会话 ${state.conversations.length} 个。凭据存在 ${configPath()}`);
156
+ return { url, token, cookie };
157
+ }
158
+
159
+ /** 一次性模式:发一句,把回复打印完就退出,方便脚本里用。 */
160
+ async function askOnce(flags, text) {
161
+ const settings = resolveSettings(flags);
162
+ if (!settings.loggedIn) throw new AuthError('还没登录,先运行 sidekick login');
163
+ const api = new SidekickApi(settings);
164
+ const state = await api.state();
165
+ const conv = pickConv(state, settings.conv);
166
+ const idleMs = Math.max(1, Number(flags.wait || 6) || 6) * 1000;
167
+ const sent = await api.send({ conv, text, device: settings.device });
168
+
169
+ const ctl = new AbortController();
170
+ const got = [];
171
+ let idleTimer = null;
172
+ let guardTimer = null;
173
+ let finishing = false;
174
+ const done = new Promise((resolve) => {
175
+ const finish = () => {
176
+ if (finishing) return;
177
+ finishing = true;
178
+ clearTimeout(idleTimer);
179
+ clearTimeout(guardTimer);
180
+ ctl.abort();
181
+ resolve();
182
+ };
183
+ ctl.signal.addEventListener('abort', finish, { once: true });
184
+ // 一直没回复也别吊着:默认 5 分钟(按 idle 时间的两倍放宽)。
185
+ guardTimer = setTimeout(finish, Math.max(120_000, idleMs * 2));
186
+ });
187
+
188
+ api
189
+ .stream({
190
+ after: sent.seq,
191
+ device: settings.device,
192
+ signal: ctl.signal,
193
+ onEvent(event, payload) {
194
+ if (event === 'message') {
195
+ if (payload.conv !== conv || payload.from === 'user') return;
196
+ got.push(payload);
197
+ if (flags.json) stdout(JSON.stringify(payload));
198
+ else stdout(plainMessage(payload, state.bots));
199
+ clearTimeout(idleTimer);
200
+ idleTimer = setTimeout(() => ctl.abort(), idleMs);
201
+ } else if (event === 'busy' && conv.startsWith('dm:') && payload.busy === false && got.length > 0) {
202
+ // 私聊里这一轮结束了,不用再等 idle;群里别的成员还在忙,只认 idle。
203
+ ctl.abort();
204
+ }
205
+ },
206
+ })
207
+ .catch((err) => {
208
+ if (!ctl.signal.aborted) fail(`推流出错:${err instanceof Error ? err.message : String(err)}`);
209
+ });
210
+
211
+ await done;
212
+ if (got.length === 0) {
213
+ fail('没等到回复。检查服务器是否在跑,或者用 sidekick 进交互看看。');
214
+ return;
215
+ }
216
+ }
217
+
218
+ /** 按 id、名字或列表序号找一个会话;找不到返回 undefined。 */
219
+ export function findConv(state, wanted) {
220
+ const ref = String(wanted || '').trim();
221
+ if (!ref) return undefined;
222
+ const byId = state.conversations.find((c) => c.id === ref);
223
+ if (byId) return byId;
224
+ const byTitle = state.conversations.find((c) => c.title === ref);
225
+ if (byTitle) return byTitle;
226
+ const index = Number(ref);
227
+ if (Number.isInteger(index) && index > 0 && index <= state.conversations.length) return state.conversations[index - 1];
228
+ return undefined;
229
+ }
230
+
231
+ /** 按 id、名字或列表序号挑会话;挑不到就退回和主 Bot 的私聊。 */
232
+ export function pickConv(state, wanted) {
233
+ const primaryConv = `dm:${state.primary}`;
234
+ const found = findConv(state, wanted);
235
+ if (found) return found.id;
236
+ if (state.conversations.some((c) => c.id === primaryConv)) return primaryConv;
237
+ return state.conversations[0]?.id ?? primaryConv;
238
+ }
239
+
240
+ /** 交互模式。 */
241
+ async function startChat(flags) {
242
+ let settings = resolveSettings(flags);
243
+ if (!settings.loggedIn) {
244
+ if (!process.stdin.isTTY) throw new AuthError('还没登录,先运行 sidekick login');
245
+ await loginCommand({ ...flags, url: flags.url || settings.url });
246
+ settings = resolveSettings(flags);
247
+ }
248
+
249
+ let api = new SidekickApi(settings);
250
+ let state;
251
+ try {
252
+ state = await api.state();
253
+ } catch (err) {
254
+ if (err instanceof AuthError) {
255
+ if (!process.stdin.isTTY) throw new AuthError('登录态已失效,重新运行 sidekick login');
256
+ await loginCommand({ ...flags, url: settings.url });
257
+ settings = resolveSettings(flags);
258
+ api = new SidekickApi(settings);
259
+ state = await api.state();
260
+ } else {
261
+ throw err;
262
+ }
263
+ }
264
+
265
+ // 老终端(不认 ANSI)和管道里别用转义序列:重画输入行会留下「[K」这类垃圾。
266
+ const vt = Boolean(process.stdout.isTTY && process.stdin.isTTY) && process.env.TERM !== 'dumb' && !flags.plain && process.env.SIDEKICK_PLAIN !== '1';
267
+ const color = flags.noColor || !vt ? false : colorsEnabled();
268
+ const convTitle = (id) => state.conversations.find((c) => c.id === id)?.title || id;
269
+ const convOf = (id) => state.conversations.find((c) => c.id === id);
270
+ let convId = pickConv(state, settings.conv);
271
+ let latest = state.latest || 0;
272
+ let everConnected = false;
273
+ const displayed = new Set();
274
+ const ctl = new AbortController();
275
+ let rl = null;
276
+ let closed = false;
277
+ let ui = null;
278
+ let streamTask = Promise.resolve();
279
+
280
+ const out = (text) => {
281
+ if (ui) ui.out(text);
282
+ else process.stdout.write(`${text}\n`);
283
+ };
284
+ const typing = (on) => ui?.typing(on);
285
+
286
+ const printMessage = (message) => {
287
+ const options = { bots: state.bots, color, imageUrl: (name) => api.imageUrl(name) };
288
+ if (ui) ui.message(message, options);
289
+ else out(formatMessage(message, options));
290
+ };
291
+ if (vt) {
292
+ const { createChatUI } = await import('./tui.mjs');
293
+ ui = createChatUI({ color, onSubmit: (text) => {
294
+ queue.push(() => text.startsWith('/') ? handleSlash(text) : sendText(text));
295
+ void drain();
296
+ }, onQuit: () => shutdown(0) });
297
+ }
298
+
299
+ async function printHistory(limit = 20) {
300
+ const body = await api.history(convId, limit);
301
+ let day = '';
302
+ for (const m of body.messages) {
303
+ const d = dayLabel(m.ts);
304
+ if (d !== day) {
305
+ out(paint(color, 'gray', `──── ${d} ────`));
306
+ day = d;
307
+ }
308
+ printMessage(m);
309
+ }
310
+ latest = Math.max(latest, body.latest || 0);
311
+ if (body.latest) api.read(convId, body.latest).catch(() => {});
312
+ if (body.messages.length === 0) out(paint(color, 'gray', '(这里还没有消息)'));
313
+ }
314
+
315
+ const primary = state.bots.find((b) => b.primary);
316
+ out(`${paint(color, 'bold', `Sidekick · ${primary?.name || state.primary}`)} ${paint(color, 'gray', settings.url)}`);
317
+ if (settings.conv && !findConv(state, settings.conv)) {
318
+ out(paint(color, 'yellow', `找不到会话「${settings.conv}」,先用「${convTitle(convId)}」;/conv 看全部`));
319
+ }
320
+ out(paint(color, 'gray', `会话:${convTitle(convId)} · /help 看命令,/conv 列会话,Ctrl-C 退出`));
321
+ await printHistory(30);
322
+
323
+ function applyState(next) {
324
+ if (!next) return;
325
+ state = next;
326
+ if (!convOf(convId)) convId = pickConv(state, '');
327
+ }
328
+
329
+ function onMessage(m) {
330
+ latest = Math.max(latest, m.seq || 0);
331
+ typing(false);
332
+ if (displayed.has(m.seq)) return;
333
+ displayed.add(m.seq);
334
+ if (displayed.size > 1000) displayed.delete(displayed.values().next().value);
335
+ if (m.conv !== convId) {
336
+ if (m.from !== 'user') {
337
+ out(paint(color, 'gray', `· ${convTitle(m.conv)} 有新消息(${oneLine(m.text, 40) || '图片'}),/to 切过去`));
338
+ }
339
+ return;
340
+ }
341
+ printMessage(m);
342
+ if (typeof m.seq === 'number') api.read(convId, m.seq).catch(() => {});
343
+ }
344
+
345
+ streamTask = api
346
+ .stream({
347
+ after: latest,
348
+ device: settings.device,
349
+ signal: ctl.signal,
350
+ onStatus: (kind) => {
351
+ // 只在「连着的时候断了」和「重新接上」各说一句,退避重试期间不刷屏。
352
+ if (kind === 'open') {
353
+ if (everConnected) out(paint(color, 'green', '已重新连上'));
354
+ everConnected = true;
355
+ } else if (kind === 'error' && everConnected) {
356
+ everConnected = false;
357
+ out(paint(color, 'red', '连接断了,正在重连…'));
358
+ }
359
+ },
360
+ onEvent(event, payload) {
361
+ if (event === 'hello' || event === 'state') applyState(payload.state ?? payload);
362
+ else if (event === 'message') onMessage(payload);
363
+ else if (event === 'busy') typing(Boolean(payload.busy));
364
+ },
365
+ })
366
+ .catch((err) => {
367
+ if (ctl.signal.aborted) return;
368
+ out(err instanceof AuthError ? '登录态已失效,重新运行 sidekick login' : `推流出错:${err.message}`);
369
+ shutdown(1);
370
+ });
371
+
372
+ function switchConv(next) {
373
+ convId = next;
374
+ const cfg = { ...readConfig(), url: settings.url, conv: convId, device: settings.device };
375
+ writeConfig(cfg);
376
+ out(paint(color, 'green', `→ ${convTitle(convId)}`));
377
+ return printHistory(30);
378
+ }
379
+
380
+ async function handleSlash(line) {
381
+ const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
382
+ const cmd = (raw || '').toLowerCase();
383
+ const arg = rest.join(' ');
384
+ switch (cmd) {
385
+ case 'help':
386
+ case '?':
387
+ out([
388
+ '/conv 列出会话',
389
+ '/to <序号|名字> 切换会话',
390
+ '/history [n] 重印最近 n 条(默认 20)',
391
+ '/who 名册和连接状态',
392
+ '/clear 清屏',
393
+ '/quit 退出(也可以 Ctrl-C)',
394
+ '直接输入文字就是发给当前会话。',
395
+ ].join('\n'));
396
+ return;
397
+ case 'quit':
398
+ case 'exit':
399
+ case 'q':
400
+ shutdown(0);
401
+ return;
402
+ case 'conv':
403
+ case 'convs':
404
+ case 'ls':
405
+ out(
406
+ state.conversations
407
+ .map((c, i) => formatConversation(c, i + 1, { bots: state.bots, current: convId }))
408
+ .join('\n') || '(还没有会话)',
409
+ );
410
+ return;
411
+ case 'to':
412
+ case 'switch':
413
+ if (!arg) {
414
+ out('用法:/to <序号或名字>,先 /conv 看一眼');
415
+ return;
416
+ }
417
+ await switchConv(pickConv(state, arg));
418
+ return;
419
+ case 'history':
420
+ case 'h':
421
+ await printHistory(Math.max(1, Number(arg) || 20));
422
+ return;
423
+ case 'who':
424
+ out(
425
+ state.bots
426
+ .map((b) => `${b.primary ? '*' : ' '}${b.name}(${b.id}) ${b.status}${b.busy ? ' 正在忙' : ''} — ${b.role || '无专长'}`)
427
+ .join('\n'),
428
+ );
429
+ return;
430
+ case 'clear':
431
+ if (ui) ui.clear();
432
+ else out('(清屏已跳过:纯文本模式下不动终端)');
433
+ return;
434
+ default:
435
+ out(paint(color, 'red', `不认识的命令:/${cmd}(/help 看命令)`));
436
+ }
437
+ }
438
+
439
+ const queue = [];
440
+ let busy = false;
441
+ async function drain() {
442
+ if (busy) return;
443
+ const next = queue.shift();
444
+ if (next === undefined) return;
445
+ busy = true;
446
+ try {
447
+ await next();
448
+ } catch (err) {
449
+ out(paint(color, 'red', err instanceof Error ? err.message : String(err)));
450
+ } finally {
451
+ busy = false;
452
+ void drain();
453
+ }
454
+ }
455
+
456
+ async function sendText(text) {
457
+ const res = await api.send({ conv: convId, text, device: settings.device });
458
+ latest = Math.max(latest, res.seq);
459
+ if (displayed.has(res.seq)) return;
460
+ displayed.add(res.seq);
461
+ printMessage({ seq: res.seq, conv: convId, ts: Date.now(), from: 'user', text });
462
+ }
463
+
464
+ if (ui) {
465
+ ui.start();
466
+ } else {
467
+ rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
468
+ rl.on('line', (line) => {
469
+ const text = line.trim();
470
+ if (!text) return;
471
+ queue.push(() => (text.startsWith('/') ? handleSlash(text) : sendText(text)));
472
+ void drain();
473
+ });
474
+ rl.on('SIGINT', () => shutdown(0));
475
+ rl.on('close', () => {
476
+ if (!closed) shutdown(0);
477
+ });
478
+ }
479
+
480
+ function shutdown(code = 0) {
481
+ if (closed) return;
482
+ closed = true;
483
+ typing(false);
484
+ ui?.stop();
485
+ ctl.abort();
486
+ try {
487
+ rl?.close();
488
+ } catch {
489
+ /* 已经关了 */
490
+ }
491
+ process.exitCode = code;
492
+ void streamTask.catch(() => {});
493
+ // 让事件循环自己收尾:Windows 上在 TLS 句柄还没关完时就 process.exit(),
494
+ // 会让 libuv 报「!(handle->flags & UV_HANDLE_CLOSING)」直接崩掉。
495
+ // 万一有句柄赖着不放,1.5 秒后强退。
496
+ setTimeout(() => process.exit(code), 1500).unref();
497
+ }
498
+ }
499
+
500
+ export async function run(argv = process.argv.slice(2)) {
501
+ let flags;
502
+ try {
503
+ flags = parseFlags(argv);
504
+ } catch (err) {
505
+ fail(`${err.message}\n\n${HELP}`);
506
+ return;
507
+ }
508
+ if (flags.help) {
509
+ stdout(HELP);
510
+ return;
511
+ }
512
+ if (flags.version) {
513
+ stdout(PACKAGE_VERSION);
514
+ return;
515
+ }
516
+ if (flags.noColor) process.env.NO_COLOR = '1';
517
+
518
+ const command = flags.positional[0];
519
+ const rest = flags.positional.slice(1);
520
+
521
+ process.on('unhandledRejection', (err) => {
522
+ fail(err instanceof Error ? err.message : String(err));
523
+ process.exit(1);
524
+ });
525
+
526
+ try {
527
+ if (command === 'help') {
528
+ stdout(HELP);
529
+ return;
530
+ }
531
+ if (command === 'login') {
532
+ await loginCommand(flags);
533
+ return;
534
+ }
535
+ if (command === 'logout') {
536
+ clearConfig();
537
+ stdout(`已经删掉本机保存的登录态(${configPath()})`);
538
+ return;
539
+ }
540
+ if (command === 'ask') {
541
+ const text = rest.join(' ').trim();
542
+ if (!text) throw new Error('用法:sidekick ask "要说的话"');
543
+ await askOnce(flags, text);
544
+ return;
545
+ }
546
+ if (command) {
547
+ // 没有子命令就当成一句话发出去:sidekick "今天有什么安排"
548
+ await askOnce(flags, flags.positional.join(' ').trim());
549
+ return;
550
+ }
551
+ await startChat(flags);
552
+ } catch (err) {
553
+ if (err instanceof AuthError) {
554
+ fail(`${err.message}`);
555
+ process.exitCode = 2;
556
+ return;
557
+ }
558
+ fail(err instanceof Error ? err.message : String(err));
559
+ }
560
+ }
package/src/prompt.mjs ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 终端里的交互输入:普通提问(带回车默认值)和遮掉回显的密码输入。
3
+ */
4
+ import { createInterface } from 'node:readline';
5
+
6
+ /** 问一句,拿一行答案;直接回车就用 `fallback`。 */
7
+ export async function ask(question, { fallback = '', input = process.stdin, output = process.stdout } = {}) {
8
+ const rl = createInterface({ input, output, terminal: Boolean(input.isTTY) });
9
+ try {
10
+ const answer = await new Promise((resolve) => {
11
+ rl.question(question, resolve);
12
+ });
13
+ const trimmed = answer.trim();
14
+ return trimmed || fallback;
15
+ } finally {
16
+ rl.close();
17
+ }
18
+ }
19
+
20
+ /** 读密码:终端里按 * 显示(不公开长度以外的信息),管道输入时直接读一行。 */
21
+ export function askPassword(question, { input = process.stdin, output = process.stdout } = {}) {
22
+ if (!input.isTTY) {
23
+ const rl = createInterface({ input, output, terminal: false });
24
+ return new Promise((resolve) => {
25
+ rl.once('line', (line) => {
26
+ rl.close();
27
+ resolve(line.replace(/\r$/, ''));
28
+ });
29
+ });
30
+ }
31
+ return new Promise((resolve) => {
32
+ output.write(question);
33
+ input.setRawMode(true);
34
+ input.resume();
35
+ let buf = '';
36
+ const finish = (value) => {
37
+ input.setRawMode(false);
38
+ input.pause();
39
+ input.off('data', onData);
40
+ output.write('\n');
41
+ resolve(value);
42
+ };
43
+ const onData = (chunk) => {
44
+ for (const ch of String(chunk)) {
45
+ if (ch === '\r' || ch === '\n') return finish(buf);
46
+ if (ch === '\u0003') {
47
+ // Ctrl-C:交还给外层,由它统一退出。
48
+ finish('');
49
+ process.kill(process.pid, 'SIGINT');
50
+ return;
51
+ }
52
+ if (ch === '\u007f' || ch === '\b') {
53
+ if (buf) {
54
+ buf = buf.slice(0, -1);
55
+ output.write('\b \b');
56
+ }
57
+ continue;
58
+ }
59
+ if (ch < ' ') continue;
60
+ buf += ch;
61
+ output.write('*');
62
+ }
63
+ };
64
+ input.on('data', onData);
65
+ });
66
+ }
package/src/tui.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import { Container, Editor, Markdown, ProcessTerminal, Text, TuiMainScreen } from '@earendil-works/pi-tui';
2
+ import { paint, formatMessage } from './format.mjs';
3
+
4
+ export function markdownTheme(color) {
5
+ const style = (code) => (text) => paint(color, code, text);
6
+ return Object.fromEntries(Object.entries({
7
+ heading: 'bold', link: 'cyan', linkUrl: 'gray', code: 'yellow',
8
+ codeBlock: 'yellow', codeBlockBorder: 'gray', quote: 'gray', quoteBorder: 'gray',
9
+ hr: 'gray', listBullet: 'cyan', bold: 'bold', italic: 'dim',
10
+ strikethrough: 'dim', underline: 'cyan',
11
+ }).map(([name, code]) => [name, style(code)]));
12
+ }
13
+
14
+ /** Pi's renderer owns every screen write, including the editor and busy status. */
15
+ export function createChatUI({ color, onSubmit, onQuit, terminal = new ProcessTerminal() }) {
16
+ const tui = new TuiMainScreen(terminal);
17
+ const transcript = new Container();
18
+ const status = new Text('', 1, 0);
19
+ const identity = (text) => text;
20
+ const editor = new Editor(tui, {
21
+ borderColor: (text) => paint(color, 'gray', text),
22
+ selectList: Object.fromEntries(['selectedPrefix', 'selectedText', 'description', 'scrollInfo', 'noMatch'].map((key) => [key, identity])),
23
+ });
24
+ tui.addChild(transcript);
25
+ tui.addChild(status);
26
+ tui.addChild(editor);
27
+ tui.addChild(new Text(paint(color, 'gray', 'Enter 发送 · Shift+Enter 换行 · ↑ 历史 · /help · Ctrl+C 退出'), 1, 0));
28
+ tui.setFocus(editor);
29
+ tui.addInputListener((data) => {
30
+ if (data === '\u0003' || data === '\u0004' && !editor.getText()) {
31
+ onQuit();
32
+ return { consume: true };
33
+ }
34
+ });
35
+ editor.onSubmit = (text) => {
36
+ if (!text.trim()) return;
37
+ editor.addToHistory(text);
38
+ editor.setText('');
39
+ onSubmit(text.trim());
40
+ tui.requestRender();
41
+ };
42
+ return {
43
+ start: () => tui.start(),
44
+ stop: () => tui.stop(),
45
+ out(text) { transcript.addChild(new Text(text, 1, 0)); tui.requestRender(); },
46
+ message(message, options) {
47
+ const header = formatMessage({ ...message, text: '', images: [] }, options).trimEnd();
48
+ transcript.addChild(new Text(`\n${header}`, 1, 0));
49
+ transcript.addChild(message.from === 'user'
50
+ ? new Text(String(message.text ?? ''), 2, 0)
51
+ : new Markdown(String(message.text ?? ''), 2, 0, markdownTheme(color)));
52
+ for (const name of message.images ?? []) transcript.addChild(new Text(`[图片] ${options.imageUrl?.(name) || name}`, 2, 0));
53
+ tui.requestRender();
54
+ },
55
+ typing(on) { status.setText(on ? paint(color, 'gray', '… 正在输入') : ''); tui.requestRender(); },
56
+ clear() { transcript.clear(); tui.requestRender(true); },
57
+ };
58
+ }