sidekick-chat 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Sidekick 终端客户端
2
2
 
3
3
  在终端里和秘书说话。它只是 `chat` World 的又一个客户端,和手机 App、网页版共用同一批会话、未读和回复,
4
- 不新增任何后端接口——所以手机上收到的、终端里发的,是同一份对话。
4
+ 所以手机上收到的、终端里发的,是同一份对话。
5
5
 
6
6
  ```
7
7
  $ npx sidekick-chat
@@ -21,6 +21,8 @@ Sidekick · 秘书 https://hkv4.cznorth.cn/chat
21
21
 
22
22
  ## 安装
23
23
 
24
+ 0.2.8 起,输入框上方显示当前会话各助理的「上下文 ≈ 已用 / 预算 token · 百分比」,每 5 秒检查更新,交接和预算设置变化后自动刷新。`/context` 查看当前会话用量,`/who` 查看全部助理。数值是当前主 session 的估算,预算使用服务器配置(默认 200000 token),与 `/usage` 的订阅额度不同;旧服务器没有数据时显示「上下文用量未知」。
25
+
24
26
  ```bash
25
27
  npx sidekick-chat # 不装直接用
26
28
  npm i -g sidekick-chat # 或者装到全局,命令就是 sidekick
@@ -42,7 +44,7 @@ Enter 发送,Shift+Enter 换行(终端需支持;也可用 Ctrl+J),↑/
42
44
 
43
45
  复制图片后按 **Ctrl+V** 或输入 **`/paste`**,图片会进入待发送区;可继续输入说明,按 Enter 一起发送。终端如果拦截 Ctrl+V(复制图片时通常不会向程序发出字符),请用 `/paste`。文字仍可用终端的 Ctrl+Shift+V / 右键粘贴。也可以 `/image "D:\图片\截图.png"` 添加文件。每条最多 4 张、每张 6 MB,支持 PNG/JPEG/GIF/WebP;`/drop` 清除待发送图片,`/send [说明]` 发送(纯文本模式用这个命令)。发送失败保留图片,切换会话前须发送或清除。
44
46
 
45
- Windows 使用系统剪贴板,无需另装工具;macOS 需安装 `pngpaste`,Linux 使用 `wl-clipboard`(Wayland)或 `xclip`(X11)。剪贴板读取发生在运行 CLI 的机器上,SSH 会话可改用 `/image` 读取远端文件。
47
+ Windows 使用系统剪贴板,无需另装工具;**WSL 里直接读 Windows 的剪贴板**(经 `powershell.exe`,截图、复制的图片都在那边),`/image` 也认 `D:\图片\a.png` 这样的 Windows 路径;macOS 需安装 `pngpaste`,其他 Linux 使用 `wl-clipboard`(Wayland)或 `xclip`(X11)。剪贴板读取发生在运行 CLI 的机器上,SSH 会话可改用 `/image` 读取远端文件。
46
48
 
47
49
  老终端可用 `sidekick --plain`:不上色、不重画输入行。管道和 `TERM=dumb` 自动使用纯文本;`--no-color` / `NO_COLOR` 只关闭颜色。一次性 `ask` 和 `--json` 保持文本/JSON 输出。
48
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-chat",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Sidekick 终端客户端:在终端里和秘书对话",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -0,0 +1,18 @@
1
+ const count = (n) => Math.round(n).toLocaleString('en-US');
2
+
3
+ export function formatContext(bot) {
4
+ const c = bot?.context;
5
+ if (!c || !Number.isFinite(c.estTokens) || c.estTokens < 0) return '上下文用量未知';
6
+ const used = count(c.estTokens);
7
+ if (!Number.isFinite(c.maxTokens) || c.maxTokens <= 0) return `上下文 ≈ ${used} token · 预算未知`;
8
+ return `上下文 ≈ ${used} / ${count(c.maxTokens)} token · ${Math.round(c.estTokens / c.maxTokens * 100)}%`;
9
+ }
10
+
11
+ export function applyContext(bots, frame) {
12
+ const bot = bots.find(b => b.id === frame?.bot);
13
+ if (!bot || !Number.isFinite(frame.at) || frame.at <= 0 || frame.at < (bot.context?.at || 0)
14
+ || !Number.isFinite(frame.maxTokens) || frame.maxTokens <= 0
15
+ || (frame.estTokens !== null && (!Number.isFinite(frame.estTokens) || frame.estTokens < 0))) return false;
16
+ bot.context = { at: frame.at, estTokens: frame.estTokens, maxTokens: frame.maxTokens };
17
+ return true;
18
+ }
package/src/images.mjs CHANGED
@@ -1,10 +1,23 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { readFile, stat } from 'node:fs/promises';
4
+ import { readFileSync } from 'node:fs';
4
5
 
5
6
  const exec = promisify(execFile);
6
7
  export const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
7
8
 
9
+ /** WSL 里跑的是 Linux 版 Node,但剪贴板在 Windows 那边:WSLg 的 Wayland 剪贴板不同步图片。 */
10
+ export function isWsl(env = process.env) {
11
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
12
+ try { return /microsoft/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8')); } catch { return false; }
13
+ }
14
+
15
+ /** WSL 里粘进来的 Windows 路径(D:\图片\a.png)换成 /mnt/d/图片/a.png。 */
16
+ export function wslPath(path) {
17
+ const m = /^([A-Za-z]):[\\/](.*)$/.exec(path);
18
+ return m ? `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}` : path;
19
+ }
20
+
8
21
  export function imageUpload(bytes) {
9
22
  if (!bytes.length || bytes.length > MAX_IMAGE_BYTES) throw new Error('图片必须在 1 字节到 6 MB 之间');
10
23
  const mime = bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) ? 'image/png'
@@ -15,32 +28,45 @@ export function imageUpload(bytes) {
15
28
  return { mime, data: bytes.toString('base64') };
16
29
  }
17
30
 
18
- export async function imageFile(path) {
19
- const file = path.replace(/^("|')(.*)\1$/, '$2');
31
+ export async function imageFile(path, { wsl = process.platform === 'linux' && isWsl() } = {}) {
32
+ const unquoted = path.replace(/^("|')(.*)\1$/, '$2');
33
+ const file = wsl ? wslPath(unquoted) : unquoted;
20
34
  const info = await stat(file);
21
35
  if (!info.isFile() || info.size > MAX_IMAGE_BYTES) throw new Error('请选择不超过 6 MB 的图片文件');
22
36
  return imageUpload(await readFile(file));
23
37
  }
24
38
 
25
39
  /** Read the local OS clipboard; never run clipboard text as a command. */
26
- export async function clipboardImage({ platform = process.platform, run = exec } = {}) {
40
+ export async function clipboardImage({ platform = process.platform, run = exec, wsl = platform === 'linux' && isWsl(), env = process.env } = {}) {
27
41
  const options = { timeout: 10000, maxBuffer: 16 * 1024 * 1024, windowsHide: true };
42
+ const script = `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $img = [Windows.Forms.Clipboard]::GetImage(); if ($null -eq $img) { exit 2 }; $stream = New-Object IO.MemoryStream; try { $img.Save($stream, [Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($stream.ToArray()) } finally { $stream.Dispose(); $img.Dispose() }`;
43
+ const windows = async () => {
44
+ const { stdout } = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], options);
45
+ return imageUpload(Buffer.from(String(stdout).trim(), 'base64'));
46
+ };
47
+ const linux = async () => {
48
+ const command = env.WAYLAND_DISPLAY ? 'wl-paste' : 'xclip';
49
+ const args = command === 'wl-paste' ? ['--type', 'image/png'] : ['-selection', 'clipboard', '-t', 'image/png', '-o'];
50
+ const { stdout } = await run(command, args, { ...options, encoding: 'buffer' });
51
+ return imageUpload(Buffer.from(stdout));
52
+ };
28
53
  try {
29
- if (platform === 'win32') {
30
- const script = `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $img = [Windows.Forms.Clipboard]::GetImage(); if ($null -eq $img) { exit 2 }; $stream = New-Object IO.MemoryStream; try { $img.Save($stream, [Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($stream.ToArray()) } finally { $stream.Dispose(); $img.Dispose() }`;
31
- const { stdout } = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], options);
32
- return imageUpload(Buffer.from(String(stdout).trim(), 'base64'));
33
- }
54
+ if (platform === 'win32') return await windows();
34
55
  if (platform === 'darwin') {
35
56
  const { stdout } = await run('pngpaste', ['-'], { ...options, encoding: 'buffer' });
36
57
  return imageUpload(Buffer.from(stdout));
37
58
  }
38
- const command = process.env.WAYLAND_DISPLAY ? 'wl-paste' : 'xclip';
39
- const args = command === 'wl-paste' ? ['--type', 'image/png'] : ['-selection', 'clipboard', '-t', 'image/png', '-o'];
40
- const { stdout } = await run(command, args, { ...options, encoding: 'buffer' });
41
- return imageUpload(Buffer.from(stdout));
59
+ if (wsl) {
60
+ // 先读 Windows 剪贴板(截图、复制的图都在那边);WSL 关了 interop 才退回 Linux 工具。
61
+ try { return await windows(); } catch (err) {
62
+ if (err.code !== 'ENOENT' && err.code !== 'EACCES') throw err;
63
+ }
64
+ }
65
+ return await linux();
42
66
  } catch (err) {
43
- if (err.code === 'ENOENT') throw new Error('缺少剪贴板工具:macOS 安装 pngpaste,Linux 安装 wl-clipboard 或 xclip;也可用 /image 图片路径');
67
+ if (err.code === 'ENOENT') throw new Error(wsl
68
+ ? 'WSL 里调不到 powershell.exe(interop 可能关了),也没装 wl-clipboard / xclip;可用 /image 图片路径(Windows 路径也行)'
69
+ : '缺少剪贴板工具:macOS 安装 pngpaste,Linux 安装 wl-clipboard 或 xclip;也可用 /image 图片路径');
44
70
  throw new Error('无法读取剪贴板图片,请先复制图片,或用 /image 图片路径');
45
71
  }
46
72
  }
package/src/main.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  * 终端客户端的主流程:`sidekick` 进交互对话,`sidekick login` 存凭据,`sidekick ask "…"` 一次性问一句。
3
3
  *
4
4
  * 它只是 chat World 的又一个客户端,和手机 App、网页版共用同一份会话和未读,
5
- * 不新增任何后端接口(见 packages/world-chat/src/world.ts 的 `startHub`)。
5
+ * 上下文用量随团队状态和 SSE 推流提供(见 packages/world-chat/src/world.ts 的 `startHub`)。
6
6
  */
7
7
  import { createInterface } from 'node:readline';
8
+ import { applyContext, formatContext } from './context.mjs';
8
9
  import { readFileSync } from 'node:fs';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { AuthError, SidekickApi, loginWithPassword } from './api.mjs';
@@ -334,7 +335,7 @@ async function startChat(flags) {
334
335
 
335
336
  function drawActivity() {
336
337
  const members = convOf(convId)?.members || [];
337
- const text = state.bots.filter(b => members.includes(b.id)).map(b => `${b.name}: ${formatActivity(b)}`).join('\n');
338
+ const text = state.bots.filter(b => members.includes(b.id)).map(b => `${b.name}: ${formatActivity(b)}\n${b.name}: ${formatContext(b)}`).join('\n');
338
339
  if (ui) ui.activity(text);
339
340
  else if (text) out(text);
340
341
  }
@@ -422,6 +423,9 @@ async function startChat(flags) {
422
423
  else if (event === 'activity' && applyActivity(state.bots, payload)) {
423
424
  if (convOf(convId)?.members.includes(payload.bot)) drawActivity();
424
425
  }
426
+ else if (event === 'context' && applyContext(state.bots, payload)) {
427
+ if (convOf(convId)?.members.includes(payload.bot)) drawActivity();
428
+ }
425
429
  else if (event === 'busy') typing(Boolean(payload.busy));
426
430
  },
427
431
  })
@@ -469,7 +473,8 @@ async function startChat(flags) {
469
473
  '/conv 列出会话',
470
474
  '/to <序号|名字> 切换会话',
471
475
  '/history [n] 重印最近 n 条(默认 20)',
472
- '/who 名册和连接状态',
476
+ '/who 名册、连接状态和上下文用量',
477
+ '/context 当前会话各助理的上下文用量和预算',
473
478
  '/usage 查询额度,查看剩余比例和重置时间',
474
479
  '/paste 添加剪贴板图片(也可 Ctrl+V)',
475
480
  '/image <路径> 添加图片文件,最多 4 张、每张 6 MB',
@@ -509,10 +514,13 @@ async function startChat(flags) {
509
514
  case 'who':
510
515
  out(
511
516
  state.bots
512
- .map((b) => `${b.primary ? '*' : ' '}${b.name}(${b.id}) ${b.status}${b.busy ? ' 正在忙' : ''} — ${b.role || '无专长'}\n ${formatActivity(b)}`)
517
+ .map((b) => `${b.primary ? '*' : ' '}${b.name}(${b.id}) ${b.status}${b.busy ? ' 正在忙' : ''} — ${b.role || '无专长'}\n ${formatActivity(b)}\n ${formatContext(b)}`)
513
518
  .join('\n'),
514
519
  );
515
520
  return;
521
+ case 'context':
522
+ out(state.bots.filter(b => convOf(convId)?.members.includes(b.id)).map(b => `${b.name}: ${formatContext(b)}`).join('\n') || '当前会话没有助理');
523
+ return;
516
524
  case 'usage':
517
525
  case 'quota':
518
526
  out(formatQuota(await quotas.refresh(), { detail: true }));