openclaw-session-ui 1.0.0

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.
Files changed (4) hide show
  1. package/README.md +52 -0
  2. package/bin/cli.js +112 -0
  3. package/index.html +371 -0
  4. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # OpenClaw Session UI (Performance & Security Enhanced) 🦄
2
+
3
+ 这是一个用于可视化 OpenClaw 会话日志(`.jsonl` 文件)的轻量级 Web 工具。经过安全加固与性能优化,特别适合在远程服务器环境下使用。
4
+
5
+ ## ✨ 核心特性
6
+
7
+ - **🛡️ 安全加固**:
8
+ - **Token 鉴权**:启动时生成随机访问令牌,防止公网未授权访问。
9
+ - **本地绑定**:强制监听 `127.0.0.1`,仅允许通过 SSH 隧道安全连接。
10
+ - **沙箱路径**:内置目录遍历保护,严禁跨目录读取文件。
11
+ - **⚡ 性能优化**:
12
+ - **懒加载 (Lazy Loading)**:侧边栏仅加载索引,点击会话后才按需读取内容,轻松应对超大日志文件。
13
+ - **分片读取头信息**:通过读取文件头部提取时间戳,无需全量读取即可完成列表排序。
14
+ - **📱 体验优化**:
15
+ - **智能排版**:自动处理超长字符串换行,防止页面被撑宽。
16
+ - **自动收纳**:超长工具入参(Tool Call)与结果(Tool Result)自动进入限高滚动窗。
17
+ - **快速导航**:提供右下角悬浮按钮,支持一键回到顶部或底部。
18
+ - **详尽详情**:每条消息均展示模型名称、Token 消耗、费用统计及停止原因。
19
+
20
+ ## 🚀 快速开始
21
+
22
+ ### 1. 安装 (Global Installation)
23
+
24
+ ```bash
25
+ npm install -g openclaw-session-ui
26
+ ```
27
+
28
+ ### 2. 在任意目录下启动 (Usage)
29
+
30
+ 进入存有 OpenClaw 会话日志(`.jsonl`)的目录,直接运行:
31
+
32
+ ```bash
33
+ osu
34
+ ```
35
+
36
+ ### 3. 安全访问 (Secure Access)
37
+ 由于服务强制绑定在 `127.0.0.1`,建议通过 SSH 隧道访问:
38
+
39
+ 1. **在本地终端建立隧道**:
40
+ ```bash
41
+ ssh -L 8000:localhost:8000 root@你的服务器IP
42
+ ```
43
+ 2. **在浏览器打开**:
44
+ 复制服务器终端输出的带 `?token=...` 的链接,粘贴到本地浏览器。
45
+
46
+ ## 📁 项目结构
47
+ - `bin/cli.js`: 安全增强版后端逻辑。
48
+ - `index.html`: 高性能单页应用前端。
49
+ - `package.json`: 依赖项管理(express, open)。
50
+
51
+ ---
52
+ *Created with 💜 by 小马 (Unicorn 🦄)*
package/bin/cli.js ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+
3
+ const express = require('express');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const open = require('open');
7
+ const crypto = require('crypto');
8
+
9
+ const app = express();
10
+ const port = 8000;
11
+
12
+ // 1. 生成随机 Token 🛡️
13
+ const token = crypto.randomBytes(16).toString('hex');
14
+ const startTime = new Date().toLocaleString();
15
+
16
+ // 2. Token 校验中间件
17
+ app.use((req, res, next) => {
18
+ const userToken = req.query.token;
19
+ if (req.path.startsWith('/api/') || req.path === '/' || req.path === '/index.html') {
20
+ if (userToken !== token) {
21
+ return res.status(403).send('<h1>403 Forbidden</h1><p>Access denied. Valid token required.</p>');
22
+ }
23
+ }
24
+ next();
25
+ });
26
+
27
+ app.use(express.static(path.join(__dirname, '..')));
28
+
29
+ // API: 仅获取文件列表(不包含内容)
30
+ app.get('/api/files', (req, res) => {
31
+ const sessionsDir = process.cwd();
32
+
33
+ fs.readdir(sessionsDir, (err, files) => {
34
+ if (err) return res.status(500).json({ error: 'Failed to read directory' });
35
+
36
+ const validFiles = files.filter(f => f.endsWith('.jsonl') || f.includes('.jsonl.reset.') || f.includes('.jsonl.deleted.'));
37
+ const fileList = [];
38
+
39
+ validFiles.forEach(file => {
40
+ const filePath = path.join(sessionsDir, file);
41
+ try {
42
+ const stats = fs.statSync(filePath);
43
+
44
+ // 仅读取文件开头一小部分来提取标题和元数据,避免全量读取 ⚡
45
+ const fd = fs.openSync(filePath, 'r');
46
+ const buffer = Buffer.alloc(4096); // 只读 4KB
47
+ fs.readSync(fd, buffer, 0, 4096, 0);
48
+ fs.closeSync(fd);
49
+ const partialContent = buffer.toString('utf-8');
50
+
51
+ let timestamp = stats.mtimeMs;
52
+ const firstLine = partialContent.split('\n').find(l => l.trim().startsWith('{'));
53
+ if (firstLine) {
54
+ try {
55
+ const data = JSON.parse(firstLine);
56
+ if (data.timestamp) {
57
+ const dateObj = new Date(data.timestamp);
58
+ if (!isNaN(dateObj.getTime())) timestamp = dateObj.getTime();
59
+ }
60
+ } catch (e) {}
61
+ }
62
+
63
+ fileList.push({
64
+ name: file,
65
+ size: stats.size,
66
+ timestamp: timestamp
67
+ });
68
+ } catch (err) {
69
+ console.error(`Error processing header for ${file}:`, err);
70
+ }
71
+ });
72
+
73
+ // 后端保持大排序,前端再细分
74
+ fileList.sort((a, b) => b.timestamp - a.timestamp);
75
+ res.json(fileList);
76
+ });
77
+ });
78
+
79
+ // API: 按需获取单个文件的全量内容
80
+ app.get('/api/file/content', (req, res) => {
81
+ const fileName = req.query.name;
82
+ if (!fileName) return res.status(400).json({ error: 'Missing name' });
83
+
84
+ const sessionsDir = process.cwd();
85
+ const filePath = path.join(sessionsDir, fileName);
86
+
87
+ // 安全检查:防止目录穿越
88
+ if (!filePath.startsWith(sessionsDir) || fileName.includes('..')) {
89
+ return res.status(403).json({ error: 'Access denied' });
90
+ }
91
+
92
+ if (!fs.existsSync(filePath)) {
93
+ return res.status(404).json({ error: 'File not found' });
94
+ }
95
+
96
+ try {
97
+ const content = fs.readFileSync(filePath, 'utf-8');
98
+ res.json({ name: fileName, content: content });
99
+ } catch (err) {
100
+ res.status(500).json({ error: 'Failed to read file' });
101
+ }
102
+ });
103
+
104
+ const hostname = '127.0.0.1';
105
+ app.listen(port, hostname, async () => {
106
+ const accessUrl = `http://${hostname}:${port}/?token=${token}`;
107
+ console.log(`\n🚀 OpenClaw Session UI - 高性能安全版`);
108
+ console.log(`=========================================`);
109
+ console.log(`访问地址: \x1b[32m${accessUrl}\x1b[0m`);
110
+ console.log(`=========================================\n`);
111
+ try { await open(accessUrl); } catch (e) {}
112
+ });
package/index.html ADDED
@@ -0,0 +1,371 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenClaw 会话可视化工具</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --bg-color: #f5f7fa;
11
+ --sidebar-bg: #2c3e50;
12
+ --sidebar-text: #ecf0f1;
13
+ --chat-bg: #ffffff;
14
+ --user-msg: #dcf8c6;
15
+ --assistant-msg: #f1f0f0;
16
+ --tool-call: #e8f4f8;
17
+ --tool-result: #fdf6e3;
18
+ --thinking-bg: #fafafa;
19
+ --text-color: #333;
20
+ }
21
+
22
+ body {
23
+ margin: 0;
24
+ padding: 0;
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
26
+ display: flex;
27
+ height: 100vh;
28
+ background-color: var(--bg-color);
29
+ color: var(--text-color);
30
+ overflow: hidden;
31
+ }
32
+
33
+ #sidebar {
34
+ width: 300px;
35
+ background-color: var(--sidebar-bg);
36
+ color: var(--sidebar-text);
37
+ display: flex;
38
+ flex-direction: column;
39
+ overflow-y: auto;
40
+ flex-shrink: 0;
41
+ }
42
+
43
+ .sidebar-header {
44
+ padding: 20px;
45
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
46
+ }
47
+
48
+ .file-input-btn {
49
+ display: block;
50
+ width: 100%;
51
+ padding: 10px;
52
+ box-sizing: border-box;
53
+ background-color: #3498db;
54
+ color: white;
55
+ text-align: center;
56
+ border-radius: 5px;
57
+ cursor: pointer;
58
+ font-weight: bold;
59
+ }
60
+
61
+ #fileList {
62
+ list-style: none;
63
+ padding: 0;
64
+ margin: 0;
65
+ }
66
+
67
+ .date-header {
68
+ background-color: rgba(0, 0, 0, 0.2);
69
+ color: #ecf0f1;
70
+ padding: 8px 15px;
71
+ font-size: 12px;
72
+ font-weight: bold;
73
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
74
+ position: sticky;
75
+ top: 0;
76
+ z-index: 10;
77
+ backdrop-filter: blur(5px);
78
+ }
79
+
80
+ .file-item {
81
+ padding: 15px 20px;
82
+ cursor: pointer;
83
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
84
+ word-break: break-all;
85
+ font-size: 14px;
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: 5px;
89
+ }
90
+
91
+ .file-item:hover { background-color: rgba(255, 255, 255, 0.1); }
92
+ .file-item.active {
93
+ background-color: rgba(52, 152, 219, 0.25);
94
+ border-left: 4px solid #3498db;
95
+ padding-left: 16px;
96
+ }
97
+
98
+ .file-time { font-size: 11px; color: #bdc3c7; }
99
+
100
+ #main {
101
+ flex: 1;
102
+ display: flex;
103
+ flex-direction: column;
104
+ min-width: 0;
105
+ position: relative;
106
+ }
107
+
108
+ #chatHeader {
109
+ padding: 20px;
110
+ background-color: #fff;
111
+ border-bottom: 1px solid #ddd;
112
+ font-weight: bold;
113
+ font-size: 18px;
114
+ flex-shrink: 0;
115
+ overflow: hidden;
116
+ text-overflow: ellipsis;
117
+ white-space: nowrap;
118
+ }
119
+
120
+ #chatBox {
121
+ flex: 1;
122
+ padding: 20px;
123
+ overflow-y: auto;
124
+ overflow-x: hidden; /* 防止横向撑开 ⚡ */
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: 15px;
128
+ scroll-behavior: smooth;
129
+ }
130
+
131
+ .message-row { display: flex; width: 100%; }
132
+ .message-row.user { justify-content: flex-end; }
133
+ .message-row.assistant { justify-content: flex-start; }
134
+
135
+ .message-bubble {
136
+ max-width: 85%;
137
+ padding: 12px 18px;
138
+ border-radius: 8px;
139
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
140
+ line-height: 1.6;
141
+ word-wrap: break-word; /* 强制换行防止顶开 ⚡ */
142
+ overflow-wrap: break-word;
143
+ min-width: 0; /* 让 flex 子元素能收缩 */
144
+ }
145
+
146
+ .user .message-bubble { background-color: var(--user-msg); border-top-right-radius: 0; }
147
+ .assistant .message-bubble { background-color: var(--assistant-msg); border-top-left-radius: 0; }
148
+
149
+ /* 防止代码块顶开页面 */
150
+ .md-content pre {
151
+ background: #2d2d2d;
152
+ color: #ccc;
153
+ padding: 12px;
154
+ border-radius: 5px;
155
+ overflow-x: auto;
156
+ white-space: pre-wrap;
157
+ word-break: break-all;
158
+ }
159
+
160
+ .md-content img { max-width: 100%; height: auto; }
161
+
162
+ .thinking-box {
163
+ background-color: var(--thinking-bg);
164
+ border-left: 3px solid #ccc;
165
+ padding: 10px;
166
+ margin-bottom: 12px;
167
+ border-radius: 4px;
168
+ font-size: 0.95em;
169
+ }
170
+
171
+ .tool-call-box { background-color: var(--tool-call); border: 1px solid #bce8f1; padding: 10px; border-radius: 5px; font-family: monospace; font-size: 13px; margin-top: 10px; max-height: 300px; overflow-y: auto; overflow-x: auto; word-break: break-all; }
172
+ .tool-call-box pre { white-space: pre-wrap; word-break: break-all; margin: 0; }
173
+ .tool-result-box { background-color: var(--tool-result); border: 1px solid #faebcc; padding: 10px; border-radius: 5px; font-family: monospace; font-size: 13px; margin-top: 10px; max-height: 400px; overflow-y: auto; overflow-x: auto; }
174
+ .tool-result-box pre { white-space: pre-wrap; word-break: break-all; margin: 0; }
175
+
176
+ .role-label { font-size: 12px; color: #888; margin-bottom: 5px; font-weight: bold; }
177
+
178
+ /* 元数据样式 ⚡ */
179
+ .meta-data {
180
+ font-size: 11px;
181
+ color: #999;
182
+ margin-top: 10px;
183
+ padding-top: 6px;
184
+ border-top: 1px dashed rgba(0, 0, 0, 0.1);
185
+ display: flex;
186
+ flex-wrap: wrap;
187
+ gap: 8px;
188
+ justify-content: flex-end;
189
+ }
190
+ .meta-item { background: rgba(0, 0, 0, 0.04); padding: 2px 6px; border-radius: 4px; }
191
+
192
+ .empty-state { margin: auto; color: #888; text-align: center; }
193
+
194
+ .loading-overlay {
195
+ position: absolute;
196
+ top: 0; left: 0; right: 0; bottom: 0;
197
+ background: rgba(255,255,255,0.7);
198
+ display: flex; align-items: center; justify-content: center;
199
+ z-index: 100; display: none;
200
+ }
201
+
202
+ /* 右下角悬浮按钮 ⚡ */
203
+ .scroll-buttons {
204
+ position: absolute;
205
+ bottom: 30px;
206
+ right: 30px;
207
+ display: flex;
208
+ flex-direction: column;
209
+ gap: 10px;
210
+ z-index: 100;
211
+ }
212
+ .scroll-btn {
213
+ width: 40px; height: 40px; border-radius: 50%; border: none;
214
+ background-color: #3498db; color: white; font-size: 18px;
215
+ cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.2);
216
+ opacity: 0.6; transition: all 0.3s ease;
217
+ display: flex; align-items: center; justify-content: center;
218
+ outline: none;
219
+ }
220
+ .scroll-btn:hover { opacity: 1; transform: scale(1.1); }
221
+ </style>
222
+ </head>
223
+ <body>
224
+ <div id="sidebar">
225
+ <div class="sidebar-header"><div class="file-input-btn" id="refreshBtn">刷新会话列表</div></div>
226
+ <ul id="fileList"></ul>
227
+ </div>
228
+
229
+ <div id="main">
230
+ <div id="chatHeader">请选择左侧会话</div>
231
+ <div id="chatBox">
232
+ <div class="empty-state"><h3>OpenClaw Session UI</h3><p>就绪</p></div>
233
+ </div>
234
+ <div id="loading" class="loading-overlay">正在读取文件内容...</div>
235
+
236
+ <div class="scroll-buttons" id="scrollButtons" style="display: none">
237
+ <button class="scroll-btn" id="scrollTopBtn" title="回到顶部">▲</button>
238
+ <button class="scroll-btn" id="scrollBottomBtn" title="回到底部">▼</button>
239
+ </div>
240
+ </div>
241
+
242
+ <script>
243
+ const fileList = document.getElementById("fileList");
244
+ const chatBox = document.getElementById("chatBox");
245
+ const chatHeader = document.getElementById("chatHeader");
246
+ const loading = document.getElementById("loading");
247
+ const refreshBtn = document.getElementById("refreshBtn");
248
+ const scrollButtons = document.getElementById("scrollButtons");
249
+ const scrollTopBtn = document.getElementById("scrollTopBtn");
250
+ const scrollBottomBtn = document.getElementById("scrollBottomBtn");
251
+
252
+ refreshBtn.onclick = loadFiles;
253
+ scrollTopBtn.onclick = () => chatBox.scrollTo({ top: 0, behavior: "smooth" });
254
+ scrollBottomBtn.onclick = () => chatBox.scrollTo({ top: chatBox.scrollHeight, behavior: "smooth" });
255
+
256
+ async function loadFiles() {
257
+ fileList.innerHTML = '<li style="padding: 20px;">加载中...</li>';
258
+ try {
259
+ const res = await fetch(`/api/files${window.location.search}`);
260
+ const files = await res.json();
261
+ fileList.innerHTML = "";
262
+ const groups = {};
263
+ files.forEach(f => {
264
+ const d = new Date(f.timestamp);
265
+ const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
266
+ if (!groups[date]) groups[date] = [];
267
+ groups[date].push(f);
268
+ });
269
+ Object.keys(groups).sort((a, b) => b.localeCompare(a)).forEach(date => {
270
+ const header = document.createElement("div");
271
+ header.className = "date-header";
272
+ header.textContent = date;
273
+ fileList.appendChild(header);
274
+
275
+ // ⚡ 修复:按日期分组后,组内按时间从小到大排序(顺序排列)
276
+ groups[date].sort((a, b) => a.timestamp - b.timestamp).forEach(file => {
277
+ const li = document.createElement("li");
278
+ li.className = "file-item";
279
+ const timeStr = new Date(file.timestamp).toTimeString().split(' ')[0]; // 获取 24 小时制时间
280
+ li.innerHTML = `
281
+ <span style="font-weight: 500;">${file.name}</span>
282
+ <span class="file-time">${timeStr} (${(file.size/1024).toFixed(1)} KB)</span>
283
+ `;
284
+ li.onclick = () => {
285
+ document.querySelectorAll(".file-item").forEach(el => el.classList.remove("active"));
286
+ li.classList.add("active");
287
+ fetchFileContent(file.name);
288
+ };
289
+ fileList.appendChild(li);
290
+ });
291
+ });
292
+ } catch (e) { fileList.innerHTML = '<li style="color:red">加载失败</li>'; }
293
+ }
294
+
295
+ async function fetchFileContent(fileName) {
296
+ loading.style.display = "flex";
297
+ // ⚡ 1. 先滑到顶部(清空前或切换时)
298
+ chatBox.scrollTo({ top: 0, behavior: "instant" });
299
+ try {
300
+ const res = await fetch(`/api/file/content${window.location.search}&name=${encodeURIComponent(fileName)}`);
301
+ const data = await res.json();
302
+ renderChat(data.name, data.content);
303
+ } catch (e) { alert("文件读取失败"); } finally { loading.style.display = "none"; }
304
+ }
305
+
306
+ function renderChat(name, content) {
307
+ chatHeader.textContent = name;
308
+ chatBox.innerHTML = "";
309
+ scrollButtons.style.display = "flex";
310
+
311
+ const lines = content.split("\n").filter(l => l.trim());
312
+ const nodes = {};
313
+ lines.forEach(l => { try { const d = JSON.parse(l); if (d.id) nodes[d.id] = d; } catch(e){} });
314
+
315
+ const leaves = Object.values(nodes).filter(n => !Object.values(nodes).some(p => p.parentId === n.id));
316
+ if (!leaves.length) return;
317
+
318
+ leaves.sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp));
319
+ let path = [], curr = leaves[0];
320
+ while(curr) { path.push(curr); curr = nodes[curr.parentId]; }
321
+ path.reverse();
322
+
323
+ path.forEach(node => {
324
+ if (node.type !== "message" || !node.message) return;
325
+ const msg = node.message;
326
+ const row = document.createElement("div");
327
+ row.className = `message-row ${msg.role === "toolResult" ? "assistant" : msg.role}`;
328
+
329
+ let html = `<div class="message-bubble"><div class="role-label">${msg.role}</div>`;
330
+ if (msg.role === "toolResult") {
331
+ const txt = (msg.content && msg.content[0] && msg.content[0].text) || "No content";
332
+ html += `<div class="tool-result-box"><strong>Result (${msg.toolName}):</strong><pre>${escapeHtml(txt)}</pre></div>`;
333
+ } else if (msg.content) {
334
+ msg.content.forEach(c => {
335
+ if (c.type === "thinking" || c.type === "reasoning") {
336
+ html += `<details class="thinking-box"><summary>🧠 思考过程</summary><div class="md-content">${marked.parse(c.text || c.thinking || "")}</div></details>`;
337
+ } else if (c.type === "text") {
338
+ html += `<div class="md-content">${marked.parse(c.text)}</div>`;
339
+ } else if (c.type === "toolCall") {
340
+ html += `<div class="tool-call-box"><strong>Tool Call: ${c.name}</strong><pre>${escapeHtml(typeof c.arguments === 'object' ? JSON.stringify(c.arguments,null,2) : c.arguments)}</pre></div>`;
341
+ }
342
+ });
343
+ }
344
+
345
+ // ⚡ 元数据(Token, Stop Reason, Cost)
346
+ let metaItems = [];
347
+ const usage = msg.usage || node.usage;
348
+ if (msg.model || node.model) metaItems.push(`⚙️ ${msg.model || node.model}`);
349
+ if (msg.stopReason || node.stopReason) metaItems.push(`🛑 ${msg.stopReason || node.stopReason}`);
350
+ if (usage) {
351
+ if (usage.totalTokens) metaItems.push(`📊 ${usage.totalTokens} tks`);
352
+ if (usage.cost && usage.cost.total > 0) metaItems.push(`💰 $${Number(usage.cost.total).toFixed(6)}`);
353
+ }
354
+ if (metaItems.length > 0) {
355
+ html += `<div class="meta-data">${metaItems.map(item => `<span class="meta-item">${escapeHtml(item)}</span>`).join('')}</div>`;
356
+ }
357
+
358
+ html += "</div>";
359
+ row.innerHTML = html;
360
+ chatBox.appendChild(row);
361
+ });
362
+
363
+ // ⚡ 2. 渲染完成后默认停留在顶部,不自动滑到底部(除非手动点)
364
+ chatBox.scrollTo({ top: 0, behavior: "instant" });
365
+ }
366
+
367
+ function escapeHtml(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
368
+ loadFiles();
369
+ </script>
370
+ </body>
371
+ </html>
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "openclaw-session-ui",
3
+ "version": "1.0.0",
4
+ "description": "CLI to visualize OpenClaw session logs",
5
+ "bin": {
6
+ "osu": "./bin/cli.js"
7
+ },
8
+ "scripts": {
9
+ "start": "node bin/cli.js"
10
+ },
11
+ "keywords": [
12
+ "openclaw",
13
+ "visualization",
14
+ "cli"
15
+ ],
16
+ "author": "",
17
+ "license": "ISC",
18
+ "dependencies": {
19
+ "express": "^4.18.2",
20
+ "open": "^8.4.2"
21
+ }
22
+ }