sidekick-chat 0.2.0 → 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/README.md +5 -0
- package/package.json +1 -1
- package/src/api.mjs +3 -2
- package/src/main.mjs +19 -1
- package/src/quota.mjs +107 -0
- package/src/tui.mjs +35 -1
package/README.md
CHANGED
|
@@ -30,6 +30,10 @@ npm i -g sidekick-chat # 或者装到全局,命令就是 sidekick
|
|
|
30
30
|
node cli/bin/sidekick.mjs
|
|
31
31
|
```
|
|
32
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
|
+
|
|
33
37
|
需要 Node 22.19 以上。交互界面使用与 Pi agent 相同的 `@earendil-works/pi-tui`,支持 Markdown、中文换行、多行粘贴、输入历史和窗口缩放。
|
|
34
38
|
|
|
35
39
|
Enter 发送,Shift+Enter 换行(终端需支持;也可用 Ctrl+J),↑/↓ 翻输入历史。回复和忙碌状态更新时保留输入草稿与光标。
|
|
@@ -71,6 +75,7 @@ sidekick --device 家里的电脑 "…" # 换一个署名(默认「终端」)
|
|
|
71
75
|
| `/to <序号\|名字>` | 切换会话 |
|
|
72
76
|
| `/history [n]` | 重印最近 n 条,默认 20 |
|
|
73
77
|
| `/who` | 名册:谁在线、谁在忙 |
|
|
78
|
+
| `/quota` | 刷新额度,查看剩余比例、采样和重置时间 |
|
|
74
79
|
| `/clear` | 清屏 |
|
|
75
80
|
| `/quit` | 退出(也可以 Ctrl-C) |
|
|
76
81
|
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export class SidekickApi {
|
|
|
61
61
|
throw last;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
async request(method, path, body) {
|
|
64
|
+
async request(method, path, body, { signal } = {}) {
|
|
65
65
|
return this.attempt(async (base) => {
|
|
66
66
|
const url = `${base}/${path.replace(/^\/+/, '')}`;
|
|
67
67
|
let res;
|
|
@@ -70,9 +70,10 @@ export class SidekickApi {
|
|
|
70
70
|
method,
|
|
71
71
|
headers: this.headers,
|
|
72
72
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
73
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
73
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
74
74
|
});
|
|
75
75
|
} catch (err) {
|
|
76
|
+
if (signal?.aborted) throw signal.reason;
|
|
76
77
|
throw new NetworkError(networkHint(url, err));
|
|
77
78
|
}
|
|
78
79
|
const data = await res.json().catch(() => ({}));
|
package/src/main.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
plainMessage,
|
|
29
29
|
} from './format.mjs';
|
|
30
30
|
import { ask, askPassword } from './prompt.mjs';
|
|
31
|
+
import { createQuotaMonitor, formatQuota } from './quota.mjs';
|
|
31
32
|
|
|
32
33
|
const PACKAGE_VERSION = (() => {
|
|
33
34
|
try {
|
|
@@ -276,6 +277,7 @@ async function startChat(flags) {
|
|
|
276
277
|
let closed = false;
|
|
277
278
|
let ui = null;
|
|
278
279
|
let streamTask = Promise.resolve();
|
|
280
|
+
const quotas = createQuotaMonitor(settings, (rows) => ui?.quota(rows));
|
|
279
281
|
|
|
280
282
|
const out = (text) => {
|
|
281
283
|
if (ui) ui.out(text);
|
|
@@ -323,7 +325,10 @@ async function startChat(flags) {
|
|
|
323
325
|
function applyState(next) {
|
|
324
326
|
if (!next) return;
|
|
325
327
|
state = next;
|
|
326
|
-
if (!convOf(convId))
|
|
328
|
+
if (!convOf(convId)) {
|
|
329
|
+
ui?.clearDrafts();
|
|
330
|
+
convId = pickConv(state, '');
|
|
331
|
+
}
|
|
327
332
|
}
|
|
328
333
|
|
|
329
334
|
function onMessage(m) {
|
|
@@ -348,6 +353,7 @@ async function startChat(flags) {
|
|
|
348
353
|
device: settings.device,
|
|
349
354
|
signal: ctl.signal,
|
|
350
355
|
onStatus: (kind) => {
|
|
356
|
+
if (kind === 'closed' || kind === 'error') ui?.clearDrafts();
|
|
351
357
|
// 只在「连着的时候断了」和「重新接上」各说一句,退避重试期间不刷屏。
|
|
352
358
|
if (kind === 'open') {
|
|
353
359
|
if (everConnected) out(paint(color, 'green', '已重新连上'));
|
|
@@ -360,6 +366,11 @@ async function startChat(flags) {
|
|
|
360
366
|
onEvent(event, payload) {
|
|
361
367
|
if (event === 'hello' || event === 'state') applyState(payload.state ?? payload);
|
|
362
368
|
else if (event === 'message') onMessage(payload);
|
|
369
|
+
else if (event === 'draft' && ui) {
|
|
370
|
+
// End frames have no conversation; remove only the matching sender/id.
|
|
371
|
+
if (payload.end || payload.conv === convId) ui.draft(payload, { bots: state.bots, color });
|
|
372
|
+
else ui.draft({ ...payload, end: true }, { bots: state.bots, color });
|
|
373
|
+
}
|
|
363
374
|
else if (event === 'busy') typing(Boolean(payload.busy));
|
|
364
375
|
},
|
|
365
376
|
})
|
|
@@ -370,6 +381,7 @@ async function startChat(flags) {
|
|
|
370
381
|
});
|
|
371
382
|
|
|
372
383
|
function switchConv(next) {
|
|
384
|
+
ui?.clearDrafts();
|
|
373
385
|
convId = next;
|
|
374
386
|
const cfg = { ...readConfig(), url: settings.url, conv: convId, device: settings.device };
|
|
375
387
|
writeConfig(cfg);
|
|
@@ -389,6 +401,7 @@ async function startChat(flags) {
|
|
|
389
401
|
'/to <序号|名字> 切换会话',
|
|
390
402
|
'/history [n] 重印最近 n 条(默认 20)',
|
|
391
403
|
'/who 名册和连接状态',
|
|
404
|
+
'/quota 刷新额度,查看剩余比例和重置时间',
|
|
392
405
|
'/clear 清屏',
|
|
393
406
|
'/quit 退出(也可以 Ctrl-C)',
|
|
394
407
|
'直接输入文字就是发给当前会话。',
|
|
@@ -427,6 +440,9 @@ async function startChat(flags) {
|
|
|
427
440
|
.join('\n'),
|
|
428
441
|
);
|
|
429
442
|
return;
|
|
443
|
+
case 'quota':
|
|
444
|
+
out(formatQuota(await quotas.refresh(), { detail: true }));
|
|
445
|
+
return;
|
|
430
446
|
case 'clear':
|
|
431
447
|
if (ui) ui.clear();
|
|
432
448
|
else out('(清屏已跳过:纯文本模式下不动终端)');
|
|
@@ -463,6 +479,7 @@ async function startChat(flags) {
|
|
|
463
479
|
|
|
464
480
|
if (ui) {
|
|
465
481
|
ui.start();
|
|
482
|
+
quotas.start();
|
|
466
483
|
} else {
|
|
467
484
|
rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
468
485
|
rl.on('line', (line) => {
|
|
@@ -481,6 +498,7 @@ async function startChat(flags) {
|
|
|
481
498
|
if (closed) return;
|
|
482
499
|
closed = true;
|
|
483
500
|
typing(false);
|
|
501
|
+
quotas.stop();
|
|
484
502
|
ui?.stop();
|
|
485
503
|
ctl.abort();
|
|
486
504
|
try {
|
package/src/quota.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { SidekickApi } from './api.mjs';
|
|
2
|
+
|
|
3
|
+
/** Same host and credentials as chat; proxy prefix or the standard direct port. */
|
|
4
|
+
export function ccwebBase(base) {
|
|
5
|
+
if (!base) return '';
|
|
6
|
+
const url = new URL(base);
|
|
7
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
8
|
+
if (path.endsWith('/chat')) url.pathname = path.slice(0, -5) + '/ccweb';
|
|
9
|
+
else if (!path && url.port === '18792') url.port = '18794';
|
|
10
|
+
else throw new Error('无法推导 ccweb 地址');
|
|
11
|
+
url.search = '';
|
|
12
|
+
url.hash = '';
|
|
13
|
+
return url.toString().replace(/\/+$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const percentage = (value) => typeof value === 'number' && Number.isFinite(value)
|
|
17
|
+
? Math.round(Math.max(0, Math.min(100, value))) : null;
|
|
18
|
+
const timestamp = (value) => {
|
|
19
|
+
const time = typeof value === 'number' ? value : Date.parse(value);
|
|
20
|
+
return Number.isFinite(time) && time > 0 ? time : null;
|
|
21
|
+
};
|
|
22
|
+
const usedWindows = (windows) => (Array.isArray(windows) ? windows : []).map((w) => ({
|
|
23
|
+
label: w.label || w.id || '?',
|
|
24
|
+
left: percentage(typeof w.percent === 'number' ? 100 - w.percent : null),
|
|
25
|
+
resetsAt: timestamp(w.resetsAt),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
export function quotaRows(values, now = Date.now()) {
|
|
29
|
+
const { opencode, codex, claude } = values;
|
|
30
|
+
const row = (label, value, windows, at, note = '') => ({
|
|
31
|
+
label, windows, at: timestamp(at),
|
|
32
|
+
stale: Boolean(value?.failed || value?.data?.stale || timestamp(at) && now - timestamp(at) > 10 * 60_000),
|
|
33
|
+
note: value?.failed ? (value.data ? '刷新失败,保留旧值' : '暂不可用') : note,
|
|
34
|
+
});
|
|
35
|
+
const oc = opencode?.data;
|
|
36
|
+
const cx = codex?.data;
|
|
37
|
+
const rows = [
|
|
38
|
+
row('OpenCode', opencode, usedWindows(oc?.windows), oc?.sampledAt, oc?.stale ? '缓存' : ''),
|
|
39
|
+
row('Codex', codex, usedWindows(cx?.windows), cx?.sampledAt, cx?.loggedIn === false ? '未登录' : ''),
|
|
40
|
+
];
|
|
41
|
+
const agents = (Array.isArray(claude?.data?.agents) ? claude.data.agents : []).filter((a) => a.engine === 'claude-code');
|
|
42
|
+
// Keep both requested channels visible even before their first report.
|
|
43
|
+
for (const name of ['WSL', 'Docker']) if (!agents.some((a) => a.name === name)) agents.push({ name });
|
|
44
|
+
for (const agent of agents) {
|
|
45
|
+
rows.push(row(`Claude ${agent.name}`, claude,
|
|
46
|
+
(Array.isArray(agent.usage?.windows) ? agent.usage.windows : []).map((w) => ({
|
|
47
|
+
label: w.label || w.key || '?', left: percentage(w.leftPct), resetsAt: timestamp(w.resetsAt),
|
|
48
|
+
})), agent.usage?.updatedAt, agent.online === false ? '离线' : ''));
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const localTime = (at) => {
|
|
54
|
+
const d = new Date(at);
|
|
55
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
56
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function formatQuota(rows, { detail = false } = {}) {
|
|
60
|
+
return rows.map((r) => {
|
|
61
|
+
const windows = r.windows.map((w) => `${w.label} ${w.left === null ? '?' : w.left + '%'}${detail && w.resetsAt ? `(${localTime(w.resetsAt)} 重置)` : ''}`).join(' / ');
|
|
62
|
+
const notes = [r.note, r.stale ? '旧数据' : '', r.at ? `${localTime(r.at)} 采样` : '尚无采样'].filter(Boolean);
|
|
63
|
+
return `${r.label} 剩余 ${windows || '未知'} · ${notes.join(' · ')}`;
|
|
64
|
+
}).join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Independent failures preserve each source's last good snapshot. No overlapping polls. */
|
|
68
|
+
export function createQuotaMonitor(settings, onUpdate, { api = new SidekickApi(settings), agentsApi, interval = 60_000 } = {}) {
|
|
69
|
+
const values = {};
|
|
70
|
+
const ctl = new AbortController();
|
|
71
|
+
let timer;
|
|
72
|
+
let pending;
|
|
73
|
+
let stopped = false;
|
|
74
|
+
let ccApi = agentsApi;
|
|
75
|
+
const sources = {
|
|
76
|
+
opencode: () => api.request('GET', 'v1/opencode/quota', undefined, { signal: ctl.signal }),
|
|
77
|
+
codex: () => api.request('GET', 'v1/codex/status', undefined, { signal: ctl.signal }),
|
|
78
|
+
claude: () => {
|
|
79
|
+
ccApi ??= new SidekickApi({ ...settings, url: ccwebBase(settings.url), alt: ccwebBase(settings.alt) });
|
|
80
|
+
return ccApi.request('GET', 'v1/agents', undefined, { signal: ctl.signal });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
function refresh() {
|
|
84
|
+
if (stopped) return Promise.resolve(quotaRows(values));
|
|
85
|
+
if (pending) return pending;
|
|
86
|
+
pending = (async () => {
|
|
87
|
+
await Promise.allSettled(Object.entries(sources).map(async ([key, fetch]) => {
|
|
88
|
+
try { values[key] = { data: await fetch(), failed: false }; }
|
|
89
|
+
catch { values[key] = { ...values[key], failed: true }; }
|
|
90
|
+
if (!stopped) onUpdate(quotaRows(values));
|
|
91
|
+
}));
|
|
92
|
+
return quotaRows(values);
|
|
93
|
+
})().finally(() => { pending = null; });
|
|
94
|
+
return pending;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
refresh,
|
|
98
|
+
start() {
|
|
99
|
+
if (timer || stopped) return;
|
|
100
|
+
onUpdate(quotaRows(values));
|
|
101
|
+
void refresh();
|
|
102
|
+
timer = setInterval(() => { void refresh(); }, interval);
|
|
103
|
+
timer.unref?.();
|
|
104
|
+
},
|
|
105
|
+
stop() { stopped = true; clearInterval(timer); ctl.abort(); },
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/tui.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Container, Editor, Markdown, ProcessTerminal, Text, TuiMainScreen } from '@earendil-works/pi-tui';
|
|
2
2
|
import { paint, formatMessage } from './format.mjs';
|
|
3
|
+
import { formatQuota } from './quota.mjs';
|
|
3
4
|
|
|
4
5
|
export function markdownTheme(color) {
|
|
5
6
|
const style = (code) => (text) => paint(color, code, text);
|
|
@@ -15,14 +16,25 @@ export function markdownTheme(color) {
|
|
|
15
16
|
export function createChatUI({ color, onSubmit, onQuit, terminal = new ProcessTerminal() }) {
|
|
16
17
|
const tui = new TuiMainScreen(terminal);
|
|
17
18
|
const transcript = new Container();
|
|
19
|
+
const live = new Container();
|
|
20
|
+
const drafts = new Map();
|
|
21
|
+
const draftKey = (frame) => `${frame.from}:${frame.id}`;
|
|
22
|
+
const dropDraft = (key) => {
|
|
23
|
+
const draft = drafts.get(key);
|
|
24
|
+
if (draft) live.removeChild(draft.container);
|
|
25
|
+
drafts.delete(key);
|
|
26
|
+
};
|
|
18
27
|
const status = new Text('', 1, 0);
|
|
28
|
+
const hud = new Text('', 1, 0);
|
|
19
29
|
const identity = (text) => text;
|
|
20
30
|
const editor = new Editor(tui, {
|
|
21
31
|
borderColor: (text) => paint(color, 'gray', text),
|
|
22
32
|
selectList: Object.fromEntries(['selectedPrefix', 'selectedText', 'description', 'scrollInfo', 'noMatch'].map((key) => [key, identity])),
|
|
23
33
|
});
|
|
24
34
|
tui.addChild(transcript);
|
|
35
|
+
tui.addChild(live);
|
|
25
36
|
tui.addChild(status);
|
|
37
|
+
tui.addChild(hud);
|
|
26
38
|
tui.addChild(editor);
|
|
27
39
|
tui.addChild(new Text(paint(color, 'gray', 'Enter 发送 · Shift+Enter 换行 · ↑ 历史 · /help · Ctrl+C 退出'), 1, 0));
|
|
28
40
|
tui.setFocus(editor);
|
|
@@ -44,6 +56,7 @@ export function createChatUI({ color, onSubmit, onQuit, terminal = new ProcessTe
|
|
|
44
56
|
stop: () => tui.stop(),
|
|
45
57
|
out(text) { transcript.addChild(new Text(text, 1, 0)); tui.requestRender(); },
|
|
46
58
|
message(message, options) {
|
|
59
|
+
if (message.draft) dropDraft(draftKey({ from: message.from, id: message.draft }));
|
|
47
60
|
const header = formatMessage({ ...message, text: '', images: [] }, options).trimEnd();
|
|
48
61
|
transcript.addChild(new Text(`\n${header}`, 1, 0));
|
|
49
62
|
transcript.addChild(message.from === 'user'
|
|
@@ -52,7 +65,28 @@ export function createChatUI({ color, onSubmit, onQuit, terminal = new ProcessTe
|
|
|
52
65
|
for (const name of message.images ?? []) transcript.addChild(new Text(`[图片] ${options.imageUrl?.(name) || name}`, 2, 0));
|
|
53
66
|
tui.requestRender();
|
|
54
67
|
},
|
|
68
|
+
draft(frame, options) {
|
|
69
|
+
if (!frame?.id || !frame.from) return;
|
|
70
|
+
const key = draftKey(frame);
|
|
71
|
+
if (frame.end) dropDraft(key);
|
|
72
|
+
else if (typeof frame.text === 'string') {
|
|
73
|
+
let draft = drafts.get(key);
|
|
74
|
+
if (!draft) {
|
|
75
|
+
const container = new Container();
|
|
76
|
+
const header = formatMessage({ from: frame.from, ts: Date.now(), text: '' }, options).trimEnd();
|
|
77
|
+
container.addChild(new Text(`\n${header} · 正在生成`, 1, 0));
|
|
78
|
+
const body = new Markdown(frame.text, 2, 0, markdownTheme(color));
|
|
79
|
+
container.addChild(body);
|
|
80
|
+
draft = { container, body };
|
|
81
|
+
drafts.set(key, draft);
|
|
82
|
+
live.addChild(container);
|
|
83
|
+
} else draft.body.setText(frame.text);
|
|
84
|
+
}
|
|
85
|
+
tui.requestRender();
|
|
86
|
+
},
|
|
87
|
+
clearDrafts() { drafts.clear(); live.clear(); tui.requestRender(); },
|
|
88
|
+
quota(rows) { hud.setText(paint(color, 'gray', formatQuota(rows))); tui.requestRender(); },
|
|
55
89
|
typing(on) { status.setText(on ? paint(color, 'gray', '… 正在输入') : ''); tui.requestRender(); },
|
|
56
|
-
clear() { transcript.clear(); tui.requestRender(true); },
|
|
90
|
+
clear() { transcript.clear(); drafts.clear(); live.clear(); tui.requestRender(true); },
|
|
57
91
|
};
|
|
58
92
|
}
|