dsh-my-observability 0.3.0 → 0.3.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/lib/audit-view.js CHANGED
@@ -11,263 +11,264 @@
11
11
  * - 不读取 strings —— 涉及界面文案的默认值集中在此,client 如需 i18n 覆盖
12
12
  * 通过参数传入。
13
13
  */
14
-
15
14
  /** 事件类型 → 中文标签(CSV 默认;client 可传 labels 覆盖)。非导出常量。 */
16
15
  const DEFAULT_CSV_LABELS = Object.freeze({
17
- time: '时间',
18
- type: '类型',
19
- tool: '工具',
20
- result: '结果',
21
- typeMap: Object.freeze({
22
- agent_status: 'agent 状态',
23
- llm_stream: '模型流',
24
- tool_call: '工具调用',
25
- tool_result: '工具结果',
26
- }),
27
- ok: '成功',
28
- fail: '失败',
29
- error: '错误',
30
- })
31
-
32
- const MAX_STATS_TOP = 50
33
-
16
+ time: '时间',
17
+ type: '类型',
18
+ tool: '工具',
19
+ result: '结果',
20
+ typeMap: Object.freeze({
21
+ agent_status: 'agent 状态',
22
+ llm_stream: '模型流',
23
+ tool_call: '工具调用',
24
+ tool_result: '工具结果',
25
+ }),
26
+ ok: '成功',
27
+ fail: '失败',
28
+ error: '错误',
29
+ });
30
+ const MAX_STATS_TOP = 50;
34
31
  /** 两位补零。 */
35
32
  function pad2(n) {
36
- return String(n).padStart(2, '0')
33
+ return String(n).padStart(2, '0');
37
34
  }
38
-
39
35
  /** 毫秒时间戳 → `YYYY-MM-DD HH:MM:SS`(本地时区);非法输入返回空串。 */
40
36
  export function formatTime(time) {
41
- if (typeof time !== 'number' || !Number.isFinite(time)) return ''
42
- const d = new Date(time)
43
- if (Number.isNaN(d.getTime())) return ''
44
- const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
45
- const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
46
- return `${date} ${clock}`
37
+ if (typeof time !== 'number' || !Number.isFinite(time))
38
+ return '';
39
+ const d = new Date(time);
40
+ if (Number.isNaN(d.getTime()))
41
+ return '';
42
+ const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
43
+ const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
44
+ return `${date} ${clock}`;
47
45
  }
48
-
49
46
  /** agent 状态事件的搜索片段。 */
50
47
  function agentStatusParts(data) {
51
- return [data.status, data.agentType].filter((part) => typeof part === 'string')
48
+ return [data.status, data.agentType].filter((part) => typeof part === 'string');
52
49
  }
53
-
54
50
  /** 模型流事件的搜索片段(含错误消息)。 */
55
51
  function llmParts(data) {
56
- const parts = [data.phase]
57
- if (typeof data.message === 'string' && data.message !== '') parts.push(data.message)
58
- return parts
52
+ const parts = [data.phase];
53
+ if (typeof data.message === 'string' && data.message !== '')
54
+ parts.push(data.message);
55
+ return parts;
59
56
  }
60
-
61
57
  /** 工具调用事件的搜索片段(工具名 + 参数键 + 参数摘要)。 */
62
58
  function toolCallParts(data) {
63
- const parts = []
64
- if (typeof data.name === 'string') parts.push(data.name)
65
- if (Array.isArray(data.args?.keys)) parts.push(...data.args.keys)
66
- if (typeof data.args?.summary === 'string' && data.args.summary !== '') parts.push(data.args.summary)
67
- return parts
59
+ const parts = [];
60
+ if (typeof data.name === 'string')
61
+ parts.push(data.name);
62
+ if (Array.isArray(data.args?.keys))
63
+ parts.push(...data.args.keys);
64
+ if (typeof data.args?.summary === 'string' && data.args.summary !== '')
65
+ parts.push(data.args.summary);
66
+ return parts;
68
67
  }
69
-
70
68
  /** 工具结果事件的搜索片段(工具名 + 成败)。 */
71
69
  function toolResultParts(data) {
72
- const parts = []
73
- if (typeof data.name === 'string') parts.push(data.name)
74
- parts.push(data.ok === false ? '失败' : '成功')
75
- return parts
70
+ const parts = [];
71
+ if (typeof data.name === 'string')
72
+ parts.push(data.name);
73
+ parts.push(data.ok === false ? '失败' : '成功');
74
+ return parts;
76
75
  }
77
-
78
76
  /** 插件事件(issue #154)的搜索片段(插件名/事件名/动作/原因/参数值)。 */
79
77
  function pluginEventParts(data) {
80
- const parts = []
81
- if (typeof data.plugin === 'string') parts.push(data.plugin)
82
- if (typeof data.event === 'string') parts.push(data.event)
83
- if (typeof data.action === 'string') parts.push(data.action)
84
- if (typeof data.reason === 'string') parts.push(data.reason)
85
- if (data.params !== null && typeof data.params === 'object') {
86
- for (const value of Object.values(data.params)) {
87
- if (typeof value === 'string' && value !== '') parts.push(value)
78
+ const parts = [];
79
+ if (typeof data.plugin === 'string')
80
+ parts.push(data.plugin);
81
+ if (typeof data.event === 'string')
82
+ parts.push(data.event);
83
+ if (typeof data.action === 'string')
84
+ parts.push(data.action);
85
+ if (typeof data.reason === 'string')
86
+ parts.push(data.reason);
87
+ if (data.params !== null && typeof data.params === 'object') {
88
+ for (const value of Object.values(data.params)) {
89
+ if (typeof value === 'string' && value !== '')
90
+ parts.push(value);
91
+ }
88
92
  }
89
- }
90
- return parts
93
+ return parts;
91
94
  }
92
-
93
95
  /** 事件类型 → 搜索片段收集函数(查表消分支)。 */
94
96
  const PARTS_COLLECTORS = {
95
- agent_status: agentStatusParts,
96
- llm_stream: llmParts,
97
- tool_call: toolCallParts,
98
- tool_result: toolResultParts,
99
- plugin_event: pluginEventParts,
100
- }
101
-
97
+ agent_status: agentStatusParts,
98
+ llm_stream: llmParts,
99
+ tool_call: toolCallParts,
100
+ tool_result: toolResultParts,
101
+ plugin_event: pluginEventParts,
102
+ };
102
103
  /** 提取事件可用于关键词匹配的文本(工具名/参数摘要/错误信息/状态/阶段等)。 */
103
104
  export function searchableText(event) {
104
- const data = event && event.data ? event.data : {}
105
- const parts = [event?.type, event?.sessionId]
106
- const collector = PARTS_COLLECTORS[event?.type]
107
- if (collector !== undefined) parts.push(...collector(data))
108
- return parts
109
- .filter((part) => typeof part === 'string')
110
- .join(' ')
111
- .toLowerCase()
105
+ const data = event && event.data ? event.data : {};
106
+ const parts = [event?.type, event?.sessionId];
107
+ const collector = PARTS_COLLECTORS[event?.type];
108
+ if (collector !== undefined)
109
+ parts.push(...collector(data));
110
+ return parts
111
+ .filter((part) => typeof part === 'string')
112
+ .join(' ')
113
+ .toLowerCase();
112
114
  }
113
-
114
115
  /** 事件是否命中关键词(不区分大小写;空关键词视为命中全部)。 */
115
116
  export function matchesKeyword(event, keyword) {
116
- const kw = String(keyword ?? '')
117
- .trim()
118
- .toLowerCase()
119
- if (kw === '') return true
120
- return searchableText(event).includes(kw)
117
+ const kw = String(keyword ?? '')
118
+ .trim()
119
+ .toLowerCase();
120
+ if (kw === '')
121
+ return true;
122
+ return searchableText(event).includes(kw);
121
123
  }
122
-
123
124
  /** 返回 `true` 表示事件具备失败语义(工具失败 / 模型流出错)。 */
124
125
  function isFailEvent(event) {
125
- if (event?.type === 'tool_result') return event.data?.ok === false
126
- if (event?.type === 'llm_stream') return event.data?.phase === 'error'
127
- return false
126
+ if (event?.type === 'tool_result')
127
+ return event.data?.ok === false;
128
+ if (event?.type === 'llm_stream')
129
+ return event.data?.phase === 'error';
130
+ return false;
128
131
  }
129
-
130
132
  /** 归一化过滤条件(时间转为闭区间数值;空值透传)。 */
131
133
  function normalizeCriteria(criteria) {
132
- const start =
133
- typeof criteria.timeStart === 'number' && Number.isFinite(criteria.timeStart) ? criteria.timeStart : undefined
134
- const end = typeof criteria.timeEnd === 'number' && Number.isFinite(criteria.timeEnd) ? criteria.timeEnd : undefined
135
- return { type: criteria.type ?? '', keyword: criteria.keyword ?? '', result: criteria.result ?? '', start, end }
134
+ const start = typeof criteria.timeStart === 'number' && Number.isFinite(criteria.timeStart) ? criteria.timeStart : undefined;
135
+ const end = typeof criteria.timeEnd === 'number' && Number.isFinite(criteria.timeEnd) ? criteria.timeEnd : undefined;
136
+ return { type: criteria.type ?? '', keyword: criteria.keyword ?? '', result: criteria.result ?? '', start, end };
136
137
  }
137
-
138
138
  /** 类型过滤('tool' 表示 tool_call + tool_result;'plugin' 表示 plugin_event)。 */
139
139
  function passType(type, filterType) {
140
- if (filterType === '') return true
141
- if (filterType === 'tool') return type === 'tool_call' || type === 'tool_result'
142
- if (filterType === 'plugin') return type === 'plugin_event'
143
- return type === filterType
140
+ if (filterType === '')
141
+ return true;
142
+ if (filterType === 'tool')
143
+ return type === 'tool_call' || type === 'tool_result';
144
+ if (filterType === 'plugin')
145
+ return type === 'plugin_event';
146
+ return type === filterType;
144
147
  }
145
-
146
148
  /** 时间范围闭区间。 */
147
149
  function passTime(time, start, end) {
148
- if (start !== undefined && time < start) return false
149
- if (end !== undefined && time > end) return false
150
- return true
150
+ if (start !== undefined && time < start)
151
+ return false;
152
+ if (end !== undefined && time > end)
153
+ return false;
154
+ return true;
151
155
  }
152
-
153
156
  /** 成功/失败过滤:只作用于有成败语义的事件,其余事件透传。 */
154
157
  function passResult(event, result) {
155
- if (result === '') return true
156
- if (result === 'success') return !isFailEvent(event)
157
- if (result === 'fail') return isFailEvent(event)
158
- return true
158
+ if (result === '')
159
+ return true;
160
+ if (result === 'success')
161
+ return !isFailEvent(event);
162
+ if (result === 'fail')
163
+ return isFailEvent(event);
164
+ return true;
159
165
  }
160
-
161
166
  /** 组合过滤:类型(tool 表示 tool_call+tool_result)+ 时间范围 + 成功/失败 + 关键词。
162
167
  * criteria: { type, timeStart, timeEnd, result, keyword } */
163
168
  export function applyAuditFilter(events, criteria = {}) {
164
- const ctx = normalizeCriteria(criteria)
165
- return (events ?? []).filter(
166
- (event) =>
167
- passType(event.type, ctx.type) &&
168
- passTime(event.time, ctx.start, ctx.end) &&
169
- passResult(event, ctx.result) &&
170
- matchesKeyword(event, ctx.keyword),
171
- )
169
+ const ctx = normalizeCriteria(criteria);
170
+ return (events ?? []).filter((event) => passType(event.type, ctx.type) &&
171
+ passTime(event.time, ctx.start, ctx.end) &&
172
+ passResult(event, ctx.result) &&
173
+ matchesKeyword(event, ctx.keyword));
172
174
  }
173
-
174
175
  /** CSV 单元格转义:含逗号/引号/换行时用双引号包裹并转义内嵌引号。 */
175
176
  export function csvCell(value) {
176
- const s = value === null || value === undefined ? '' : String(value)
177
- return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
177
+ const s = value === null || value === undefined ? '' : String(value);
178
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
178
179
  }
179
-
180
180
  /** 事件的工具名(仅 tool_call/tool_result;否则空)。 */
181
181
  export function toolNameOf(event) {
182
- if (event?.type === 'tool_call' || event?.type === 'tool_result') return String(event.data?.name ?? '')
183
- return ''
182
+ if (event?.type === 'tool_call' || event?.type === 'tool_result')
183
+ return String(event.data?.name ?? '');
184
+ return '';
184
185
  }
185
-
186
186
  /** 事件的结果摘要(成功/失败/错误;其余空)。 */
187
187
  export function resultTextOf(event, labels = DEFAULT_CSV_LABELS) {
188
- if (event?.type === 'tool_result') return event.data?.ok === false ? labels.fail : labels.ok
189
- if (event?.type === 'llm_stream' && event.data?.phase === 'error') return labels.error
190
- return ''
188
+ if (event?.type === 'tool_result')
189
+ return event.data?.ok === false ? labels.fail : labels.ok;
190
+ if (event?.type === 'llm_stream' && event.data?.phase === 'error')
191
+ return labels.error;
192
+ return '';
191
193
  }
192
-
193
194
  /** 生成 CSV 摘要(表头:时间/类型/工具/结果)。labels 可覆盖默认中文。
194
195
  * 返回不含换行结尾符的 CSV 文本。 */
195
196
  export function auditToCsv(events, labels = DEFAULT_CSV_LABELS) {
196
- const typeMap = labels.typeMap ?? {}
197
- const header = [labels.time, labels.type, labels.tool, labels.result]
198
- const lines = [header.map(csvCell).join(',')]
199
- for (const event of events ?? []) {
200
- const typeLabel = typeMap[event.type] ?? String(event.type)
201
- const row = [formatTime(event.time), typeLabel, toolNameOf(event), resultTextOf(event, labels)]
202
- lines.push(row.map(csvCell).join(','))
203
- }
204
- return lines.join('\n')
197
+ const typeMap = labels.typeMap ?? {};
198
+ const header = [labels.time, labels.type, labels.tool, labels.result];
199
+ const lines = [header.map(csvCell).join(',')];
200
+ for (const event of (events ?? [])) {
201
+ const typeLabel = typeMap[event.type] ?? String(event.type);
202
+ const row = [formatTime(event.time), typeLabel, toolNameOf(event), resultTextOf(event, labels)];
203
+ lines.push(row.map(csvCell).join(','));
204
+ }
205
+ return lines.join('\n');
205
206
  }
206
-
207
207
  /** 生成 JSON 完整数据(缩进默认 2)。 */
208
208
  export function auditToJson(events, space = 2) {
209
- return JSON.stringify(events ?? [], null, space)
209
+ return JSON.stringify(events ?? [], null, space);
210
210
  }
211
-
212
211
  /** 事件是否为工具类(tool_call / tool_result)。 */
213
212
  function isToolEvent(event) {
214
- return event?.type === 'tool_call' || event?.type === 'tool_result'
213
+ return event?.type === 'tool_call' || event?.type === 'tool_result';
215
214
  }
216
-
217
215
  /** 把单条工具事件计入聚合(调用次数 / 失败次数)。 */
218
216
  function bumpTool(byTool, event, name) {
219
- const entry = byTool.get(name) ?? { tool: name, calls: 0, fails: 0 }
220
- if (event.type === 'tool_call') entry.calls += 1
221
- if (event.type === 'tool_result' && event.data?.ok === false) entry.fails += 1
222
- byTool.set(name, entry)
217
+ const entry = byTool.get(name) ?? { tool: name, calls: 0, fails: 0 };
218
+ if (event.type === 'tool_call')
219
+ entry.calls += 1;
220
+ if (event.type === 'tool_result' && event.data?.ok === false)
221
+ entry.fails += 1;
222
+ byTool.set(name, entry);
223
223
  }
224
-
225
224
  /** 按工具名聚合调用次数与失败次数。 */
226
225
  function aggregateToolStats(events) {
227
- const byTool = new Map()
228
- for (const event of events ?? []) {
229
- if (!isToolEvent(event)) continue
230
- const name = String(event.data?.name ?? '')
231
- if (name === '') continue
232
- bumpTool(byTool, event, name)
233
- }
234
- return byTool
226
+ const byTool = new Map();
227
+ for (const event of (events ?? [])) {
228
+ if (!isToolEvent(event))
229
+ continue;
230
+ const name = String(event.data?.name ?? '');
231
+ if (name === '')
232
+ continue;
233
+ bumpTool(byTool, event, name);
234
+ }
235
+ return byTool;
235
236
  }
236
-
237
237
  /** 聚合结果 → 排序 + 失败率列表。 */
238
238
  function rankTools(byTool) {
239
- return [...byTool.values()]
240
- .map((entry) => ({ ...entry, failRate: entry.calls > 0 ? entry.fails / entry.calls : 0 }))
241
- .sort((a, b) => b.calls - a.calls || b.fails - a.fails || a.tool.localeCompare(b.tool))
239
+ return [...byTool.values()]
240
+ .map((entry) => ({ ...entry, failRate: entry.calls > 0 ? entry.fails / entry.calls : 0 }))
241
+ .sort((a, b) => b.calls - a.calls || b.fails - a.fails || a.tool.localeCompare(b.tool));
242
242
  }
243
-
244
243
  /** 工具调用统计:每个工具调用次数 + 失败率(topN 截断,默认 5)。
245
244
  * 返回 [{ tool, calls, fails, failRate }] 按调用次数降序。 */
246
245
  export function computeToolStats(events, topN = 5) {
247
- const n = typeof topN === 'number' && topN > 0 ? Math.min(topN, MAX_STATS_TOP) : 5
248
- return rankTools(aggregateToolStats(events)).slice(0, n)
246
+ const n = typeof topN === 'number' && topN > 0 ? Math.min(topN, MAX_STATS_TOP) : 5;
247
+ return rankTools(aggregateToolStats(events)).slice(0, n);
249
248
  }
250
-
251
249
  /** 把 text 按 keyword 切成 [ { text, hit } ] 分段(用于命中关键词高亮)。
252
250
  * 空关键词返回整段未命中。 */
253
251
  export function highlightSegments(text, keyword) {
254
- const raw = String(text ?? '')
255
- const kw = String(keyword ?? '')
256
- .trim()
257
- .toLowerCase()
258
- if (kw === '') return [{ text: raw, hit: false }]
259
- const lower = raw.toLowerCase()
260
- const out = []
261
- let cursor = 0
262
- for (;;) {
263
- const idx = lower.indexOf(kw, cursor)
264
- if (idx === -1) {
265
- if (cursor < raw.length) out.push({ text: raw.slice(cursor), hit: false })
266
- break
252
+ const raw = String(text ?? '');
253
+ const kw = String(keyword ?? '')
254
+ .trim()
255
+ .toLowerCase();
256
+ if (kw === '')
257
+ return [{ text: raw, hit: false }];
258
+ const lower = raw.toLowerCase();
259
+ const out = [];
260
+ let cursor = 0;
261
+ for (;;) {
262
+ const idx = lower.indexOf(kw, cursor);
263
+ if (idx === -1) {
264
+ if (cursor < raw.length)
265
+ out.push({ text: raw.slice(cursor), hit: false });
266
+ break;
267
+ }
268
+ if (idx > cursor)
269
+ out.push({ text: raw.slice(cursor, idx), hit: false });
270
+ out.push({ text: raw.slice(idx, idx + kw.length), hit: true });
271
+ cursor = idx + kw.length;
267
272
  }
268
- if (idx > cursor) out.push({ text: raw.slice(cursor, idx), hit: false })
269
- out.push({ text: raw.slice(idx, idx + kw.length), hit: true })
270
- cursor = idx + kw.length
271
- }
272
- return out.length === 0 ? [{ text: raw, hit: false }] : out
273
+ return out.length === 0 ? [{ text: raw, hit: false }] : out;
273
274
  }