sidekick-chat 0.2.5 → 0.2.7

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
@@ -17,6 +17,8 @@ Sidekick · 秘书 https://hkv4.cznorth.cn/chat
17
17
  记成明天 9:30,提前 10 分钟提醒。
18
18
  ```
19
19
 
20
+ 0.2.6 起,当前会话各 bot 的最后 LLM 调用时间和对应活动常驻显示在输入框上方,并实时更新;`/who` 查看全部 bot,`/conv` 也列出成员活动。纯文本交互在启动、切换会话及活动变化时输出。时间为调用开始时间,忙闲、消息和心跳不刷新它;没有记录时显示「尚无 LLM 活动记录」。
21
+
20
22
  ## 安装
21
23
 
22
24
  ```bash
@@ -40,7 +42,7 @@ Enter 发送,Shift+Enter 换行(终端需支持;也可用 Ctrl+J),↑/
40
42
 
41
43
  复制图片后按 **Ctrl+V** 或输入 **`/paste`**,图片会进入待发送区;可继续输入说明,按 Enter 一起发送。终端如果拦截 Ctrl+V(复制图片时通常不会向程序发出字符),请用 `/paste`。文字仍可用终端的 Ctrl+Shift+V / 右键粘贴。也可以 `/image "D:\图片\截图.png"` 添加文件。每条最多 4 张、每张 6 MB,支持 PNG/JPEG/GIF/WebP;`/drop` 清除待发送图片,`/send [说明]` 发送(纯文本模式用这个命令)。发送失败保留图片,切换会话前须发送或清除。
42
44
 
43
- Windows 使用系统剪贴板,无需另装工具;macOS 需安装 `pngpaste`,Linux 使用 `wl-clipboard`(Wayland)或 `xclip`(X11)。剪贴板读取发生在运行 CLI 的机器上,SSH 会话可改用 `/image` 读取远端文件。
45
+ Windows 使用系统剪贴板,无需另装工具;**WSL 里直接读 Windows 的剪贴板**(经 `powershell.exe`,截图、复制的图片都在那边),`/image` 也认 `D:\图片\a.png` 这样的 Windows 路径;macOS 需安装 `pngpaste`,其他 Linux 使用 `wl-clipboard`(Wayland)或 `xclip`(X11)。剪贴板读取发生在运行 CLI 的机器上,SSH 会话可改用 `/image` 读取远端文件。
44
46
 
45
47
  老终端可用 `sidekick --plain`:不上色、不重画输入行。管道和 `TERM=dumb` 自动使用纯文本;`--no-color` / `NO_COLOR` 只关闭颜色。一次性 `ask` 和 `--json` 保持文本/JSON 输出。
46
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-chat",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Sidekick 终端客户端:在终端里和秘书对话",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -0,0 +1,17 @@
1
+ import { dayLabel, clockTime, oneLine } from './format.mjs';
2
+
3
+ export function formatActivity(bot) {
4
+ const at = bot?.lastActiveAt;
5
+ if (!Number.isFinite(at) || at <= 0 || Number.isNaN(new Date(at).getTime())) return '尚无 LLM 活动记录';
6
+ const seconds = String(new Date(at).getSeconds()).padStart(2, '0');
7
+ return `最后活跃 ${dayLabel(at)} ${clockTime(at)}:${seconds} · ${oneLine(bot.lastActivity || 'LLM 调用', 80)}`;
8
+ }
9
+
10
+ /** 旧帧不覆盖新记录;心跳、忙闲和消息都不走这里。 */
11
+ export function applyActivity(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.lastActiveAt || 0)) return false;
14
+ bot.lastActiveAt = frame.at;
15
+ bot.lastActivity = typeof frame.text === 'string' ? frame.text : '';
16
+ return true;
17
+ }
package/src/api.mjs CHANGED
@@ -106,7 +106,7 @@ export class SidekickApi {
106
106
 
107
107
  /**
108
108
  * 订阅推流,断了按退避重连,重连时带上最后的 seq 把漏掉的补齐。
109
- * `onEvent(event, payload)` 收到 hello / state / message / draft / thinking / busy / read。
109
+ * `onEvent(event, payload)` 收到 hello / state / message / draft / thinking / busy / activity / read。
110
110
  * 返回的 promise 只在 abort 时结束。
111
111
  */
112
112
  async stream({ after = 0, device, signal, onEvent, onStatus }) {
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
@@ -30,6 +30,7 @@ import {
30
30
  import { ask, askPassword } from './prompt.mjs';
31
31
  import { createQuotaReader, formatQuota } from './quota.mjs';
32
32
  import { clipboardImage, imageFile } from './images.mjs';
33
+ import { formatActivity, applyActivity } from './activity.mjs';
33
34
 
34
35
  const PACKAGE_VERSION = (() => {
35
36
  try {
@@ -331,6 +332,13 @@ async function startChat(flags) {
331
332
  }, onQuit: () => shutdown(0) });
332
333
  }
333
334
 
335
+ function drawActivity() {
336
+ 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
+ if (ui) ui.activity(text);
339
+ else if (text) out(text);
340
+ }
341
+
334
342
  async function printHistory(limit = 20) {
335
343
  const body = await api.history(convId, limit);
336
344
  let day = '';
@@ -356,6 +364,7 @@ async function startChat(flags) {
356
364
  }
357
365
  out(paint(color, 'gray', `会话:${convTitle(convId)} · /help 看命令,/conv 列会话,Ctrl-C 退出`));
358
366
  await printHistory(30);
367
+ drawActivity();
359
368
 
360
369
  function applyState(next) {
361
370
  if (!next) return;
@@ -364,6 +373,7 @@ async function startChat(flags) {
364
373
  ui?.clearDrafts();
365
374
  convId = pickConv(state, '');
366
375
  }
376
+ drawActivity();
367
377
  }
368
378
 
369
379
  function onMessage(m) {
@@ -409,6 +419,9 @@ async function startChat(flags) {
409
419
  if (payload.end || payload.conv === convId) ui.draft(payload, { bots: state.bots, color });
410
420
  else ui.draft({ ...payload, end: true }, { bots: state.bots, color });
411
421
  }
422
+ else if (event === 'activity' && applyActivity(state.bots, payload)) {
423
+ if (convOf(convId)?.members.includes(payload.bot)) drawActivity();
424
+ }
412
425
  else if (event === 'busy') typing(Boolean(payload.busy));
413
426
  },
414
427
  })
@@ -423,6 +436,7 @@ async function startChat(flags) {
423
436
  ui?.clearDrafts();
424
437
  markPresent();
425
438
  convId = next;
439
+ drawActivity();
426
440
  const cfg = { ...readConfig(), url: settings.url, conv: convId, device: settings.device };
427
441
  writeConfig(cfg);
428
442
  out(paint(color, 'green', `→ ${convTitle(convId)}`));
@@ -476,7 +490,7 @@ async function startChat(flags) {
476
490
  case 'ls':
477
491
  out(
478
492
  state.conversations
479
- .map((c, i) => formatConversation(c, i + 1, { bots: state.bots, current: convId }))
493
+ .map((c, i) => formatConversation(c, i + 1, { bots: state.bots, current: convId }) + '\n' + c.members.map(id => state.bots.find(b => b.id === id)).filter(Boolean).map(b => ` ${b.name}: ${formatActivity(b)}`).join('\n'))
480
494
  .join('\n') || '(还没有会话)',
481
495
  );
482
496
  return;
@@ -495,7 +509,7 @@ async function startChat(flags) {
495
509
  case 'who':
496
510
  out(
497
511
  state.bots
498
- .map((b) => `${b.primary ? '*' : ' '}${b.name}(${b.id}) ${b.status}${b.busy ? ' 正在忙' : ''} — ${b.role || '无专长'}`)
512
+ .map((b) => `${b.primary ? '*' : ' '}${b.name}(${b.id}) ${b.status}${b.busy ? ' 正在忙' : ''} — ${b.role || '无专长'}\n ${formatActivity(b)}`)
499
513
  .join('\n'),
500
514
  );
501
515
  return;
package/src/tui.mjs CHANGED
@@ -23,6 +23,7 @@ export function createChatUI({ color, onSubmit, onQuit, onInput, onPasteImage, h
23
23
  if (draft) live.removeChild(draft.container);
24
24
  drafts.delete(key);
25
25
  };
26
+ const activity = new Text('', 1, 0);
26
27
  const status = new Text('', 1, 0);
27
28
  const identity = (text) => text;
28
29
  const editor = new Editor(tui, {
@@ -31,6 +32,7 @@ export function createChatUI({ color, onSubmit, onQuit, onInput, onPasteImage, h
31
32
  });
32
33
  tui.addChild(transcript);
33
34
  tui.addChild(live);
35
+ tui.addChild(activity);
34
36
  tui.addChild(status);
35
37
  tui.addChild(editor);
36
38
  const attachments = new Text('', 1, 0);
@@ -91,6 +93,7 @@ export function createChatUI({ color, onSubmit, onQuit, onInput, onPasteImage, h
91
93
  tui.requestRender();
92
94
  },
93
95
  clearDrafts() { drafts.clear(); live.clear(); tui.requestRender(); },
96
+ activity(text) { activity.setText(paint(color, 'gray', text)); tui.requestRender(); },
94
97
  typing(on) { status.setText(on ? paint(color, 'gray', '… 正在输入') : ''); tui.requestRender(); },
95
98
  clear() { transcript.clear(); drafts.clear(); live.clear(); tui.requestRender(true); },
96
99
  };