nx-kv 0.8.3 → 0.8.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.8.4] - 2026-10-12
6
+
7
+ ### Added
8
+
9
+ - **主题总览加「实时连接」探活指示灯**:1.5s 数据轮询 + EventSource 双心跳,
10
+ 主题总览标题旁显示「实时连接正常 / 连接断开,正在重连… / 正在连接…」三态
11
+ (断线用红色语义提示);轮询失败不再每 1.5s 弹错误 toast,统一由指示灯提示。
12
+ - **主题现状 / 上一轮完成总结 / 长任务文本支持点击弹窗阅读**:在原位完整展示基础上,
13
+ 叠加点击在居中模态框中专注阅读(长任务文本长度 >50 时才显示可点击指针);
14
+ 支持点遮罩、按 Esc 或「关闭」按钮退出。纯前端交互、无新接口。
15
+ - **wait 生命周期事件流(后端 SSE)**:
16
+ - HTTP 为 **SSE** `GET /api/localtasks/events`(可带 `cwd`/`key` 过滤,缺省订阅全部主题,
17
+ 首帧 `: connected`、每 15s 心跳、断线自动重连);CLI 等价为
18
+ `localtask events [--cwd|--key]`,逐行 NDJSON。
19
+ - 事件三类:`waiting`(开始等待,连接时已在等待补发 `replay` 快照)、
20
+ `reminder`(剩余 **30s / 10s** 各提醒一次)、`resolved`(`delivered` 被回填 /
21
+ `timeout` 超时;对等待标志残留的僵死状态补发 `stale:true` 的 timeout)。
22
+ - 后端生成器 `watchEvents` 与传输 `streamEventsSSE` 解耦,单测可独立驱动生成器。
23
+ - `api.js` 增 `action.stream === true` 机制:透传 req/res 给 action.run,
24
+ 由 action 自己接管响应(SSE 等长连接用得到)。
25
+
5
26
  ## [0.8.3] - 2026-10-12
6
27
 
7
28
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -282,5 +282,20 @@ export default {
282
282
  summary: '探测主面板上当前打开的「实时任务」标签数(活动 SSE 连接数;用于 wait 不重复开页)',
283
283
  run: () => service.panelPresence(),
284
284
  },
285
+
286
+ {
287
+ // 等待生命周期事件流:SSE 端点(带 cwd/key 过滤),由 watchEvents 派生 waiting/reminder/resolved。
288
+ // action 标 stream:true → api.js 透传 req/res,run 自己接管响应不走 sendJson。
289
+ id: 'localtask.events',
290
+ cli: ['localtask', 'events'],
291
+ http: ['GET', '/api/localtasks/events'],
292
+ summary: '等待事件流:waiting 开始 / reminder 30s·10s / resolved delivered|timeout(SSE 长连接)',
293
+ flags: {
294
+ cwd: { type: 'string', hint: '工作目录过滤(与 --key 二选一)' },
295
+ key: { type: 'string', hint: '主题 key 过滤(与 --cwd 二选一)' },
296
+ },
297
+ stream: true,
298
+ run: (ctx) => service.streamEventsSSE(ctx.__raw.req, ctx.__raw.res, { cwd: ctx.cwd, key: ctx.key }),
299
+ },
285
300
  ],
286
301
  };
@@ -991,3 +991,159 @@ async function ensureMainPanel() {
991
991
  }
992
992
  return { running: false, started: true, url };
993
993
  }
994
+
995
+ // ─── 等待生命周期事件流(主面板及时通知,避免用户漏过超时)────────────
996
+ //
997
+ // wait 与常驻主面板通常是两个进程,内存 EventEmitter 跨不过进程,所以事件流
998
+ // 不依赖进程内回调,而是轮询 store,从 waiting/waitingUntil 与「已领取数」的变化里
999
+ // 派生事件:waiting / reminder(剩余 30s、10s)/ resolved(delivered 或 timeout)。
1000
+ // HTTP(SSE) 与 CLI(NDJSON) 共用本生成器。
1001
+
1002
+ const EVENT_POLL_MS = 500;
1003
+ const REMINDER_SEC = [30, 10]; // 剩余 ≤30s、≤10s 各提醒一次
1004
+
1005
+ // 纯函数:给定剩余秒数与已提醒过的阈值,返回本次应触发的阈值(秒)。便于单测。
1006
+ export function remindersDue(remainSec, firedSet) {
1007
+ const out = [];
1008
+ for (const sec of REMINDER_SEC) {
1009
+ if (remainSec <= sec && remainSec > 0 && !firedSet.has(sec)) out.push(sec);
1010
+ }
1011
+ return out;
1012
+ }
1013
+
1014
+ function consumedCount(t) {
1015
+ return (Array.isArray(t.tasks) ? t.tasks : []).filter((x) => x.status === 'consumed').length;
1016
+ }
1017
+
1018
+ /**
1019
+ * 订阅等待生命周期事件(异步生成器)。
1020
+ * @param {{cwd?:string,key?:string,pollMs?:number,signal?:AbortSignal}} opts
1021
+ * 不传 cwd/key 则订阅全部主题(主面板总览需要)。
1022
+ */
1023
+ export async function* watchEvents({ cwd: rawCwd, key: rawKey, pollMs = EVENT_POLL_MS, signal } = {}) {
1024
+ let onlyKey = null;
1025
+ if (rawKey && String(rawKey).trim()) onlyKey = String(rawKey).trim();
1026
+ else if (rawCwd && String(rawCwd).trim()) onlyKey = normalizeCwd(rawCwd).key;
1027
+
1028
+ const sleep = (ms) =>
1029
+ new Promise((res) => {
1030
+ const to = setTimeout(res, ms);
1031
+ if (signal) {
1032
+ signal.addEventListener(
1033
+ 'abort',
1034
+ () => {
1035
+ clearTimeout(to);
1036
+ res();
1037
+ },
1038
+ { once: true }
1039
+ );
1040
+ }
1041
+ });
1042
+
1043
+ const state = new Map();
1044
+ let first = true;
1045
+ while (!(signal && signal.aborted)) {
1046
+ let topics = [];
1047
+ try {
1048
+ const store = await loadStore();
1049
+ topics = Object.values(store.topics).filter(
1050
+ (t) => Array.isArray(t.tasks) && (!onlyKey || t.key === onlyKey)
1051
+ );
1052
+ } catch {
1053
+ topics = [];
1054
+ }
1055
+
1056
+ const now = Date.now();
1057
+ for (const t of topics) {
1058
+ const deadline = parseStampMs(t.waitingUntil);
1059
+ const isWaiting = !!t.waiting && Number.isFinite(deadline) && deadline > now;
1060
+ let st = state.get(t.key);
1061
+
1062
+ if (isWaiting) {
1063
+ if (!st || st.phase !== 'waiting' || st.waitingUntil !== t.waitingUntil) {
1064
+ st = {
1065
+ phase: 'waiting',
1066
+ waitingUntil: t.waitingUntil,
1067
+ deadline,
1068
+ consumed0: consumedCount(t),
1069
+ reminders: new Set(),
1070
+ };
1071
+ state.set(t.key, st);
1072
+ yield {
1073
+ type: 'waiting',
1074
+ key: t.key,
1075
+ cwd: t.cwd || '',
1076
+ name: t.name || '',
1077
+ summary: t.summary || '',
1078
+ waitingUntil: t.waitingUntil,
1079
+ remainMs: Math.max(0, deadline - now),
1080
+ replay: first,
1081
+ };
1082
+ }
1083
+ const remainSec = Math.floor((st.deadline - now) / 1000);
1084
+ for (const sec of remindersDue(remainSec, st.reminders)) {
1085
+ st.reminders.add(sec);
1086
+ yield {
1087
+ type: 'reminder',
1088
+ key: t.key,
1089
+ cwd: t.cwd || '',
1090
+ name: t.name || '',
1091
+ summary: t.summary || '',
1092
+ waitingUntil: t.waitingUntil,
1093
+ remainSec: sec,
1094
+ remainMs: Math.max(0, st.deadline - now),
1095
+ label: `${sec}s`,
1096
+ };
1097
+ }
1098
+ } else if (st && st.phase === 'waiting') {
1099
+ const outcome = consumedCount(t) > st.consumed0 ? 'delivered' : 'timeout';
1100
+ state.set(t.key, { phase: 'ended' });
1101
+ yield { type: 'resolved', key: t.key, cwd: t.cwd || '', name: t.name || '', outcome };
1102
+ } else if (!st) {
1103
+ state.set(t.key, { phase: 'idle' });
1104
+ }
1105
+
1106
+ // 兜底:waiting 标志还在但截止已过(wait 进程异常退出),也派生一次 timeout
1107
+ if (t.waiting && (!Number.isFinite(deadline) || deadline <= now)) {
1108
+ const cur = state.get(t.key);
1109
+ if (!cur || cur.phase === 'waiting') {
1110
+ state.set(t.key, { phase: 'ended' });
1111
+ yield {
1112
+ type: 'resolved',
1113
+ key: t.key,
1114
+ cwd: t.cwd || '',
1115
+ name: t.name || '',
1116
+ outcome: 'timeout',
1117
+ stale: true,
1118
+ };
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ first = false;
1124
+ await sleep(pollMs);
1125
+ }
1126
+ }
1127
+
1128
+ // HTTP 传输:SSE。注册到 sseConns 让 panelPresence 计入(wait 据此判活标签)。
1129
+ // 客户端断开即中止生成器。SSE 心跳由 addEventStreamConn 统一管。
1130
+ export async function streamEventsSSE(req, res, { cwd, key } = {}) {
1131
+ res.writeHead(200, {
1132
+ 'content-type': 'text/event-stream; charset=utf-8',
1133
+ 'cache-control': 'no-store',
1134
+ connection: 'keep-alive',
1135
+ });
1136
+ res.write(': connected\n\n');
1137
+ // addEventStreamConn 内部已把 conn 加入 sseConns(panelPresence 据此统计活标签),
1138
+ // 我们不再直接引用——心跳 / 失败剔除 / 断开清理都由它托管。
1139
+ addEventStreamConn(req, res);
1140
+ const ac = new AbortController();
1141
+ req.on('close', () => ac.abort());
1142
+ try {
1143
+ for await (const ev of watchEvents({ cwd, key, signal: ac.signal })) {
1144
+ res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
1145
+ }
1146
+ } catch {
1147
+ /* 客户端断开 / 瞬时读盘错误,静默结束 */
1148
+ }
1149
+ }
@@ -54,6 +54,11 @@ export default function SessionTaskView() {
54
54
  const [suffixEditing, setSuffixEditing] = useState(false);
55
55
  const [suffixDraft, setSuffixDraft] = useState('');
56
56
  const [suffixSaving, setSuffixSaving] = useState(false);
57
+ // 探活状态:'connecting' | 'ok' | 'reconnecting'(数据轮询 + 后续 SSE 接入)
58
+ const [liveStatus, setLiveStatus] = useState('connecting');
59
+ // 「只读弹窗」:点击现状/上一轮总结/任务文本时显示聚焦阅读的模态框
60
+ // modal = { title, body } | null
61
+ const [modal, setModal] = useState(null);
57
62
  const [editingId, setEditingId] = useState(null);
58
63
  const [editText, setEditText] = useState('');
59
64
  const [editSaving, setEditSaving] = useState(false);
@@ -69,11 +74,14 @@ export default function SessionTaskView() {
69
74
 
70
75
  const load = useCallback(async () => {
71
76
  try {
72
- setOverview(await api('/api/localtasks'));
73
- } catch (e) {
74
- toast(e.message);
77
+ const data = await api('/api/localtasks');
78
+ setOverview(data);
79
+ setLiveStatus('ok');
80
+ } catch {
81
+ // 轮询失败 → 进入「重连中」态;指示灯变红,避免每 1.5s 弹错误 toast
82
+ setLiveStatus('reconnecting');
75
83
  }
76
- }, [toast]);
84
+ }, []);
77
85
 
78
86
  useEffect(() => {
79
87
  load();
@@ -85,6 +93,16 @@ export default function SessionTaskView() {
85
93
  return () => clearInterval(t);
86
94
  }, [load]);
87
95
 
96
+ // 只读弹窗:按 Esc 关闭
97
+ useEffect(() => {
98
+ if (!modal) return;
99
+ const onKey = (e) => {
100
+ if (e.key === 'Escape') setModal(null);
101
+ };
102
+ window.addEventListener('keydown', onKey);
103
+ return () => window.removeEventListener('keydown', onKey);
104
+ }, [modal]);
105
+
88
106
  const topics = overview ? overview.topics : [];
89
107
 
90
108
  // 同步页面上的超时配置(来源:后端 settings)
@@ -495,7 +513,35 @@ export default function SessionTaskView() {
495
513
  style={{ margin: 0 }}
496
514
  />
497
515
  <h3>主题总览(按工作目录)</h3>
498
- <span className="muted">{overview ? `${topics.length} 个 · 等待中 ${overview.waiting}` : ''}</span>
516
+ <span
517
+ className="muted"
518
+ style={{
519
+ fontSize: 11,
520
+ display: 'inline-flex',
521
+ alignItems: 'center',
522
+ gap: 4,
523
+ color: liveStatus === 'ok' ? '#2a9d5c' : liveStatus === 'reconnecting' ? '#c0392b' : '#888',
524
+ }}
525
+ title={
526
+ liveStatus === 'ok'
527
+ ? '实时连接正常(数据轮询可达后端)'
528
+ : liveStatus === 'reconnecting'
529
+ ? '连接断开,正在重连…'
530
+ : '正在连接…'
531
+ }
532
+ >
533
+ <span
534
+ style={{
535
+ display: 'inline-block',
536
+ width: 8,
537
+ height: 8,
538
+ borderRadius: '50%',
539
+ background: liveStatus === 'ok' ? '#2a9d5c' : liveStatus === 'reconnecting' ? '#c0392b' : '#aaa',
540
+ }}
541
+ />
542
+ {liveStatus === 'ok' ? '实时连接正常' : liveStatus === 'reconnecting' ? '连接断开,正在重连…' : '正在连接…'}
543
+ </span>
544
+ <span className="muted">{overview ? ` · ${topics.length} 个 · 等待中 ${overview.waiting}` : ''}</span>
499
545
  <span style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
500
546
  <button
501
547
  className="btn small ghost"
@@ -557,7 +603,12 @@ export default function SessionTaskView() {
557
603
  </div>
558
604
  </div>
559
605
  {t.summary && (
560
- <div className="muted" style={{ fontSize: 11, whiteSpace: 'pre-wrap', marginBottom: 6 }}>
606
+ <div
607
+ className="muted"
608
+ style={{ fontSize: 11, whiteSpace: 'pre-wrap', marginBottom: 6, cursor: 'zoom-in' }}
609
+ title="点击查看完整现状"
610
+ onClick={(e) => { e.stopPropagation(); setModal({ title: `主题「${t.name}」现状`, body: t.summary }); }}
611
+ >
561
612
  现状:{t.summary}
562
613
  </div>
563
614
  )}
@@ -712,12 +763,22 @@ export default function SessionTaskView() {
712
763
  </>
713
764
  ) : (
714
765
  <>
715
- {t.text}
766
+ <span
767
+ style={{ cursor: t.text.length > 50 ? 'zoom-in' : 'default' }}
768
+ onClick={(e) => {
769
+ e.stopPropagation();
770
+ if (t.text.length > 50) setModal({ title: `任务 #${t.id} 全文`, body: t.text });
771
+ }}
772
+ title={t.text.length > 50 ? '点击查看完整任务文本' : undefined}
773
+ >
774
+ {t.text}
775
+ </span>
716
776
  {t.roundSummary && (
717
777
  <div
718
778
  className="muted"
719
- style={{ fontSize: 11, marginTop: 4, paddingTop: 4, borderTop: '1px dashed var(--line,#e6e8eb)', whiteSpace: 'pre-wrap' }}
720
- title="该任务所回应的 agent 上一轮完成总结"
779
+ style={{ fontSize: 11, marginTop: 4, paddingTop: 4, borderTop: '1px dashed var(--line,#e6e8eb)', whiteSpace: 'pre-wrap', cursor: 'zoom-in' }}
780
+ title="点击查看完整上一轮总结"
781
+ onClick={(e) => { e.stopPropagation(); setModal({ title: '上一轮完成总结', body: t.roundSummary }); }}
721
782
  >
722
783
  上一轮完成:{t.roundSummary}
723
784
  </div>
@@ -777,6 +838,38 @@ export default function SessionTaskView() {
777
838
  )}
778
839
 
779
840
  <CliHints module="localtask" />
841
+
842
+ {modal && (
843
+ <div
844
+ onClick={() => setModal(null)}
845
+ style={{
846
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
847
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
848
+ }}
849
+ >
850
+ <div
851
+ onClick={(e) => e.stopPropagation()}
852
+ style={{
853
+ background: '#fff', borderRadius: 8, padding: 20, maxWidth: 'min(720px, 90vw)',
854
+ maxHeight: '80vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
855
+ }}
856
+ >
857
+ <div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
858
+ <strong style={{ fontSize: 14 }}>{modal.title}</strong>
859
+ <button
860
+ className="btn small ghost"
861
+ onClick={() => setModal(null)}
862
+ style={{ marginLeft: 'auto' }}
863
+ >
864
+ 关闭
865
+ </button>
866
+ </div>
867
+ <div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
868
+ {modal.body}
869
+ </div>
870
+ </div>
871
+ </div>
872
+ )}
780
873
  </>
781
874
  );
782
875
  }
@@ -92,6 +92,11 @@ export async function handleApi(req, res, url) {
92
92
  } else {
93
93
  Object.assign(raw, await readBody(req).catch(() => ({})));
94
94
  }
95
+ // stream 模式:把 req/res/url 透传给 run,让 action 自己接管响应(SSE 等)
96
+ if (action.stream) {
97
+ await action.run({ ...applySpec(action, raw), __raw: { req, res, url } }, { transport: 'http' });
98
+ return;
99
+ }
95
100
  const data = await action.run(applySpec(action, raw), { transport: 'http' });
96
101
  sendJson(res, 200, { ok: true, data });
97
102
  } catch (err) {
@@ -1 +1 @@
1
- import{o as e,s as t,t as n}from"./index-Iz1qH6TC.js";var r=t();function i({module:t}){let{boot:i}=e(),a=(i?.commands||[]).filter(e=>e.module===t);return a.length?(0,r.jsxs)(`div`,{className:`cli-hint`,children:[(0,r.jsx)(`span`,{className:`cli-hint-label`,children:`CLI 等价(同构命令,加 --json 得机器可读输出):`}),a.map(e=>(0,r.jsx)(n,{className:`cli-cmd`,text:e.command,title:`点击复制:${e.usage}`,children:e.command},e.id))]}):null}export{i as t};
1
+ import{o as e,s as t,t as n}from"./index-2OK9SG2y.js";var r=t();function i({module:t}){let{boot:i}=e(),a=(i?.commands||[]).filter(e=>e.module===t);return a.length?(0,r.jsxs)(`div`,{className:`cli-hint`,children:[(0,r.jsx)(`span`,{className:`cli-hint-label`,children:`CLI 等价(同构命令,加 --json 得机器可读输出):`}),a.map(e=>(0,r.jsx)(n,{className:`cli-cmd`,text:e.command,title:`点击复制:${e.usage}`,children:e.command},e.id))]}):null}export{i as t};