claude-opencode-viewer 2.3.1 → 2.4.1
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/.claude/settings.local.json +2 -1
- package/index.html +764 -36
- package/package.json +2 -1
- package/server.js +248 -6
package/index.html
CHANGED
|
@@ -29,6 +29,366 @@
|
|
|
29
29
|
justify-content: space-between;
|
|
30
30
|
flex-shrink: 0;
|
|
31
31
|
height: 40px;
|
|
32
|
+
gap: 12px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#session-history-bar {
|
|
36
|
+
display: none;
|
|
37
|
+
position: absolute;
|
|
38
|
+
top: 0;
|
|
39
|
+
left: 0;
|
|
40
|
+
right: 0;
|
|
41
|
+
bottom: 0;
|
|
42
|
+
background: #0a0a0a;
|
|
43
|
+
z-index: 1000;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#session-history-bar.visible {
|
|
48
|
+
display: flex;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#session-list-view {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
flex: 1;
|
|
55
|
+
min-height: 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#session-list-view.hidden {
|
|
59
|
+
display: none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#session-history-header {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
justify-content: space-between;
|
|
66
|
+
padding: 12px 16px;
|
|
67
|
+
background: #111;
|
|
68
|
+
border-bottom: 1px solid #222;
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#session-history-title {
|
|
73
|
+
font-size: 14px;
|
|
74
|
+
color: #ddd;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#session-history-actions {
|
|
79
|
+
display: flex;
|
|
80
|
+
gap: 8px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#session-list-container {
|
|
84
|
+
flex: 1;
|
|
85
|
+
overflow-y: auto;
|
|
86
|
+
padding: 12px;
|
|
87
|
+
-webkit-overflow-scrolling: touch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#session-detail-view {
|
|
91
|
+
display: none;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
flex: 1;
|
|
94
|
+
min-height: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#session-detail-view.visible {
|
|
98
|
+
display: flex;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#session-detail-header {
|
|
102
|
+
padding: 12px 16px;
|
|
103
|
+
background: #0d0d0d;
|
|
104
|
+
border-bottom: 1px solid #222;
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#session-detail-title {
|
|
109
|
+
font-size: 14px;
|
|
110
|
+
color: #ddd;
|
|
111
|
+
font-weight: 500;
|
|
112
|
+
margin-bottom: 4px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#session-detail-meta {
|
|
116
|
+
font-size: 11px;
|
|
117
|
+
color: #888;
|
|
118
|
+
display: flex;
|
|
119
|
+
gap: 12px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#session-detail-content {
|
|
123
|
+
flex: 1;
|
|
124
|
+
overflow-y: auto;
|
|
125
|
+
padding: 16px;
|
|
126
|
+
-webkit-overflow-scrolling: touch;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.message-item {
|
|
130
|
+
margin-bottom: 20px;
|
|
131
|
+
display: flex;
|
|
132
|
+
gap: 12px;
|
|
133
|
+
align-items: flex-start;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* User 消息:左对齐 */
|
|
137
|
+
.message-user {
|
|
138
|
+
justify-content: flex-start;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Assistant 消息:右对齐 */
|
|
142
|
+
.message-assistant {
|
|
143
|
+
justify-content: flex-end;
|
|
144
|
+
flex-direction: row-reverse;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.message-avatar {
|
|
148
|
+
flex-shrink: 0;
|
|
149
|
+
width: 32px;
|
|
150
|
+
height: 32px;
|
|
151
|
+
border-radius: 50%;
|
|
152
|
+
background: #2a4a7c;
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
justify-content: center;
|
|
156
|
+
font-size: 14px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.message-assistant .message-avatar {
|
|
160
|
+
background: #1a5a3a;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.message-content {
|
|
164
|
+
flex: 1;
|
|
165
|
+
min-width: 0;
|
|
166
|
+
max-width: 80%;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.message-header {
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
gap: 8px;
|
|
173
|
+
margin-bottom: 6px;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* User 的标题左对齐 */
|
|
177
|
+
.message-user .message-header {
|
|
178
|
+
justify-content: flex-start;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* Assistant 的标题右对齐 */
|
|
182
|
+
.message-assistant .message-header {
|
|
183
|
+
justify-content: flex-end;
|
|
184
|
+
flex-direction: row-reverse;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.message-role {
|
|
188
|
+
font-size: 11px;
|
|
189
|
+
color: #888;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
text-transform: uppercase;
|
|
192
|
+
letter-spacing: 0.5px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.message-text {
|
|
196
|
+
color: #ddd;
|
|
197
|
+
font-size: 13px;
|
|
198
|
+
line-height: 1.6;
|
|
199
|
+
white-space: pre-wrap;
|
|
200
|
+
word-break: break-word;
|
|
201
|
+
background: #141414;
|
|
202
|
+
padding: 12px;
|
|
203
|
+
border-radius: 8px;
|
|
204
|
+
border: 1px solid #222;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* User 消息气泡 */
|
|
208
|
+
.message-user .message-text {
|
|
209
|
+
background: #1a2332;
|
|
210
|
+
border-color: #2a4a7c;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Assistant 消息气泡 */
|
|
214
|
+
.message-assistant .message-text {
|
|
215
|
+
background: #1a2e1a;
|
|
216
|
+
border-color: #2a5a3a;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.message-tool-call {
|
|
220
|
+
margin-top: 8px;
|
|
221
|
+
padding: 8px 12px;
|
|
222
|
+
background: #1a1a0a;
|
|
223
|
+
border: 1px solid #333;
|
|
224
|
+
border-radius: 6px;
|
|
225
|
+
font-size: 12px;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.message-tool-name {
|
|
229
|
+
color: #f0ad4e;
|
|
230
|
+
font-weight: 600;
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
gap: 6px;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.message-tool-result {
|
|
237
|
+
margin-top: 6px;
|
|
238
|
+
padding: 8px;
|
|
239
|
+
background: #0a0a0a;
|
|
240
|
+
border-radius: 4px;
|
|
241
|
+
font-size: 11px;
|
|
242
|
+
color: #999;
|
|
243
|
+
max-height: 100px;
|
|
244
|
+
overflow-y: auto;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.message-empty {
|
|
248
|
+
text-align: center;
|
|
249
|
+
padding: 40px 20px;
|
|
250
|
+
color: #666;
|
|
251
|
+
font-size: 13px;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#session-list {
|
|
255
|
+
display: flex;
|
|
256
|
+
flex-direction: column;
|
|
257
|
+
gap: 6px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.session-item {
|
|
261
|
+
display: flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
padding: 8px 12px;
|
|
265
|
+
background: #1a1a1a;
|
|
266
|
+
border: 1px solid #333;
|
|
267
|
+
border-radius: 6px;
|
|
268
|
+
cursor: pointer;
|
|
269
|
+
transition: all 0.15s;
|
|
270
|
+
min-width: 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.session-item:hover {
|
|
274
|
+
background: #252525;
|
|
275
|
+
border-color: #555;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.session-item.active {
|
|
279
|
+
background: #2a4a7c;
|
|
280
|
+
border-color: #4a8cff;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.session-icon {
|
|
284
|
+
flex-shrink: 0;
|
|
285
|
+
width: 8px;
|
|
286
|
+
height: 8px;
|
|
287
|
+
border-radius: 50%;
|
|
288
|
+
background: #666;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.session-item.active .session-icon {
|
|
292
|
+
background: #4a8cff;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.session-info {
|
|
296
|
+
flex: 1;
|
|
297
|
+
min-width: 0;
|
|
298
|
+
display: flex;
|
|
299
|
+
flex-direction: column;
|
|
300
|
+
gap: 2px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.session-title {
|
|
304
|
+
font-size: 13px;
|
|
305
|
+
color: #ddd;
|
|
306
|
+
font-weight: 500;
|
|
307
|
+
line-height: 1.4;
|
|
308
|
+
display: -webkit-box;
|
|
309
|
+
-webkit-line-clamp: 2;
|
|
310
|
+
-webkit-box-orient: vertical;
|
|
311
|
+
overflow: hidden;
|
|
312
|
+
text-overflow: ellipsis;
|
|
313
|
+
word-break: break-word;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.session-item.active .session-title {
|
|
317
|
+
color: #fff;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.session-meta {
|
|
321
|
+
font-size: 11px;
|
|
322
|
+
color: #888;
|
|
323
|
+
display: flex;
|
|
324
|
+
gap: 8px;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.session-time {
|
|
328
|
+
flex-shrink: 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.session-dir {
|
|
332
|
+
overflow: hidden;
|
|
333
|
+
text-overflow: ellipsis;
|
|
334
|
+
white-space: nowrap;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.history-toggle-btn {
|
|
338
|
+
background: none;
|
|
339
|
+
border: 1px solid #333;
|
|
340
|
+
color: #aaa;
|
|
341
|
+
padding: 4px 10px;
|
|
342
|
+
font-size: 12px;
|
|
343
|
+
cursor: pointer;
|
|
344
|
+
border-radius: 4px;
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
gap: 4px;
|
|
348
|
+
flex-shrink: 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.history-toggle-btn:hover {
|
|
352
|
+
background: #2a2a2a;
|
|
353
|
+
color: #ddd;
|
|
354
|
+
border-color: #555;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.history-toggle-btn svg {
|
|
358
|
+
width: 14px;
|
|
359
|
+
height: 14px;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.session-loading {
|
|
363
|
+
text-align: center;
|
|
364
|
+
padding: 16px;
|
|
365
|
+
color: #888;
|
|
366
|
+
font-size: 12px;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.session-empty {
|
|
370
|
+
text-align: center;
|
|
371
|
+
padding: 16px;
|
|
372
|
+
color: #666;
|
|
373
|
+
font-size: 12px;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.session-restore-hint {
|
|
377
|
+
margin-top: 8px;
|
|
378
|
+
padding: 8px 12px;
|
|
379
|
+
background: #1a1a1a;
|
|
380
|
+
border: 1px solid #333;
|
|
381
|
+
border-radius: 6px;
|
|
382
|
+
font-size: 11px;
|
|
383
|
+
color: #888;
|
|
384
|
+
text-align: center;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.session-restore-hint code {
|
|
388
|
+
color: #4a8cff;
|
|
389
|
+
background: #0a0a0a;
|
|
390
|
+
padding: 2px 6px;
|
|
391
|
+
border-radius: 3px;
|
|
32
392
|
}
|
|
33
393
|
|
|
34
394
|
#mode-switcher {
|
|
@@ -134,6 +494,13 @@
|
|
|
134
494
|
<div id="layout">
|
|
135
495
|
<div id="header">
|
|
136
496
|
<div style="font-size: 12px; color: #aaa;">Claude OpenCode Viewer</div>
|
|
497
|
+
<button class="history-toggle-btn" id="history-toggle">
|
|
498
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
499
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
500
|
+
<polyline points="12 6 12 12 16 14"></polyline>
|
|
501
|
+
</svg>
|
|
502
|
+
<span>历史</span>
|
|
503
|
+
</button>
|
|
137
504
|
<div id="mode-switcher">
|
|
138
505
|
<span id="mode-label">Mode:</span>
|
|
139
506
|
<select id="mode-select">
|
|
@@ -143,6 +510,70 @@
|
|
|
143
510
|
</div>
|
|
144
511
|
</div>
|
|
145
512
|
|
|
513
|
+
<!-- 会话历史栏 -->
|
|
514
|
+
<div id="session-history-bar">
|
|
515
|
+
<!-- 会话列表视图 -->
|
|
516
|
+
<div id="session-list-view">
|
|
517
|
+
<div id="session-history-header">
|
|
518
|
+
<div id="session-history-title">历史会话</div>
|
|
519
|
+
<div id="session-history-actions">
|
|
520
|
+
<button class="history-toggle-btn" id="refresh-sessions">
|
|
521
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
522
|
+
<polyline points="23 4 23 10 17 10"></polyline>
|
|
523
|
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
|
524
|
+
</svg>
|
|
525
|
+
<span>刷新</span>
|
|
526
|
+
</button>
|
|
527
|
+
<button class="history-toggle-btn" id="close-history">
|
|
528
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
529
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
530
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
531
|
+
</svg>
|
|
532
|
+
<span>返回</span>
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
<div id="session-list-container">
|
|
537
|
+
<div id="session-list">
|
|
538
|
+
<div class="session-loading">加载历史会话...</div>
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<!-- 会话详情视图 -->
|
|
544
|
+
<div id="session-detail-view">
|
|
545
|
+
<div id="session-history-header">
|
|
546
|
+
<div id="session-history-title">会话详情</div>
|
|
547
|
+
<div id="session-history-actions">
|
|
548
|
+
<button class="history-toggle-btn" id="restore-session" style="background: #1a5a3a; border-color: #2a7a4a;">
|
|
549
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
550
|
+
<polyline points="1 4 1 10 7 10"></polyline>
|
|
551
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
|
552
|
+
</svg>
|
|
553
|
+
<span>恢复会话</span>
|
|
554
|
+
</button>
|
|
555
|
+
<button class="history-toggle-btn" id="back-to-list">
|
|
556
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
557
|
+
<line x1="19" y1="12" x2="5" y2="12"></line>
|
|
558
|
+
<polyline points="12 19 5 12 12 5"></polyline>
|
|
559
|
+
</svg>
|
|
560
|
+
<span>返回列表</span>
|
|
561
|
+
</button>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
<div id="session-detail-header">
|
|
565
|
+
<div id="session-detail-title">会话标题</div>
|
|
566
|
+
<div id="session-detail-meta">
|
|
567
|
+
<span id="session-detail-time">时间</span>
|
|
568
|
+
<span id="session-detail-dir">目录</span>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
<div id="session-detail-content">
|
|
572
|
+
<div class="message-empty">加载中...</div>
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
|
|
146
577
|
<div id="content">
|
|
147
578
|
<div id="terminal-container">
|
|
148
579
|
<div id="terminal"></div>
|
|
@@ -208,6 +639,12 @@
|
|
|
208
639
|
var CACHE_KEY = 'claude_opencode_input_cache';
|
|
209
640
|
var cacheRestored = false; // 防止重复恢复
|
|
210
641
|
|
|
642
|
+
// 会话历史相关
|
|
643
|
+
var sessions = [];
|
|
644
|
+
var currentSessionId = null;
|
|
645
|
+
var currentSessionData = null;
|
|
646
|
+
var historyBarVisible = false;
|
|
647
|
+
|
|
211
648
|
function saveInputCache() {
|
|
212
649
|
if (currentInputBuffer) {
|
|
213
650
|
localStorage.setItem(CACHE_KEY, currentInputBuffer);
|
|
@@ -559,6 +996,42 @@
|
|
|
559
996
|
// 设置初始选中项
|
|
560
997
|
modeSelect.value = currentMode;
|
|
561
998
|
|
|
999
|
+
// 只绑定一次 term.onData,避免重连时重复绑定
|
|
1000
|
+
term.onData(function(d) {
|
|
1001
|
+
if (ws && ws.readyState === 1) {
|
|
1002
|
+
ws.send(JSON.stringify({ type: 'input', data: d }));
|
|
1003
|
+
|
|
1004
|
+
// 缓存管理:跟踪当前行的输入(仅在 opencode 模式且未在恢复中)
|
|
1005
|
+
if (currentMode === 'opencode' && cacheRestored) {
|
|
1006
|
+
if (d === '\r' || d === '\n' || d === '\r\n') {
|
|
1007
|
+
// 回车:命令已发送,清除缓存
|
|
1008
|
+
clearInputCache();
|
|
1009
|
+
} else if (d === '\x7f' || d === '\b') {
|
|
1010
|
+
// 退格:删除最后一个字符
|
|
1011
|
+
if (currentInputBuffer.length > 0) {
|
|
1012
|
+
currentInputBuffer = currentInputBuffer.slice(0, -1);
|
|
1013
|
+
saveInputCache();
|
|
1014
|
+
}
|
|
1015
|
+
} else if (d === '\x03') {
|
|
1016
|
+
// Ctrl+C:中断,清除缓存
|
|
1017
|
+
clearInputCache();
|
|
1018
|
+
} else if (d === '\x15') {
|
|
1019
|
+
// Ctrl+U:清空整行
|
|
1020
|
+
clearInputCache();
|
|
1021
|
+
} else if (d.length === 1 && d.charCodeAt(0) >= 32 && d.charCodeAt(0) < 127) {
|
|
1022
|
+
// 可打印 ASCII 字符:添加到缓冲区
|
|
1023
|
+
currentInputBuffer += d;
|
|
1024
|
+
saveInputCache();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// 点击终端输入时,滚动到底部
|
|
1030
|
+
setTimeout(function() {
|
|
1031
|
+
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
|
1032
|
+
}, 50);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
562
1035
|
function connect() {
|
|
563
1036
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
564
1037
|
ws = new WebSocket(proto + '//' + location.host + '/ws');
|
|
@@ -576,7 +1049,10 @@
|
|
|
576
1049
|
try {
|
|
577
1050
|
var msg = JSON.parse(e.data);
|
|
578
1051
|
if (msg.type === 'data') throttledWrite(msg.data);
|
|
579
|
-
else if (msg.type === 'exit')
|
|
1052
|
+
else if (msg.type === 'exit') {
|
|
1053
|
+
throttledWrite('\r\n\x1b[33m[进程已退出: ' + msg.exitCode + ']\x1b[0m\r\n');
|
|
1054
|
+
throttledWrite('\x1b[90m按 Enter 键重新启动 ' + currentMode + '...\x1b[0m\r\n');
|
|
1055
|
+
}
|
|
580
1056
|
else if (msg.type === 'mode') {
|
|
581
1057
|
endTransition(msg.mode);
|
|
582
1058
|
// 模式切换完成后,重新绑定触摸事件
|
|
@@ -594,44 +1070,19 @@
|
|
|
594
1070
|
modeIndicator.textContent = msg.mode === 'claude' ? 'Claude' : 'OpenCode';
|
|
595
1071
|
}
|
|
596
1072
|
}
|
|
1073
|
+
else if (msg.type === 'restored') {
|
|
1074
|
+
// 会话恢复成功
|
|
1075
|
+
term.write('\x1b[32m✓ 会话已恢复: ' + msg.sessionId + '\x1b[0m\r\n');
|
|
1076
|
+
term.write('\x1b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n');
|
|
1077
|
+
term.write('\r\n');
|
|
1078
|
+
}
|
|
1079
|
+
else if (msg.type === 'restore-error') {
|
|
1080
|
+
// 恢复失败
|
|
1081
|
+
term.write('\x1b[31m✗ 恢复失败: ' + msg.error + '\x1b[0m\r\n');
|
|
1082
|
+
}
|
|
597
1083
|
} catch(err) {}
|
|
598
1084
|
};
|
|
599
1085
|
|
|
600
|
-
term.onData(function(d) {
|
|
601
|
-
if (ws && ws.readyState === 1) {
|
|
602
|
-
ws.send(JSON.stringify({ type: 'input', data: d }));
|
|
603
|
-
|
|
604
|
-
// 缓存管理:跟踪当前行的输入(仅在 opencode 模式且未在恢复中)
|
|
605
|
-
if (currentMode === 'opencode' && cacheRestored) {
|
|
606
|
-
if (d === '\r' || d === '\n' || d === '\r\n') {
|
|
607
|
-
// 回车:命令已发送,清除缓存
|
|
608
|
-
clearInputCache();
|
|
609
|
-
} else if (d === '\x7f' || d === '\b') {
|
|
610
|
-
// 退格:删除最后一个字符
|
|
611
|
-
if (currentInputBuffer.length > 0) {
|
|
612
|
-
currentInputBuffer = currentInputBuffer.slice(0, -1);
|
|
613
|
-
saveInputCache();
|
|
614
|
-
}
|
|
615
|
-
} else if (d === '\x03') {
|
|
616
|
-
// Ctrl+C:中断,清除缓存
|
|
617
|
-
clearInputCache();
|
|
618
|
-
} else if (d === '\x15') {
|
|
619
|
-
// Ctrl+U:清空整行
|
|
620
|
-
clearInputCache();
|
|
621
|
-
} else if (d.length === 1 && d.charCodeAt(0) >= 32 && d.charCodeAt(0) < 127) {
|
|
622
|
-
// 可打印 ASCII 字符:添加到缓冲区
|
|
623
|
-
currentInputBuffer += d;
|
|
624
|
-
saveInputCache();
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// 点击终端输入时,滚动到底部
|
|
630
|
-
setTimeout(function() {
|
|
631
|
-
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
|
632
|
-
}, 50);
|
|
633
|
-
});
|
|
634
|
-
|
|
635
1086
|
// 页面加载后尝试恢复缓存的输入(仅在 opencode 模式)
|
|
636
1087
|
setTimeout(function() {
|
|
637
1088
|
if (currentMode === 'opencode') {
|
|
@@ -798,6 +1249,283 @@
|
|
|
798
1249
|
});
|
|
799
1250
|
}
|
|
800
1251
|
|
|
1252
|
+
// 会话历史功能
|
|
1253
|
+
function formatTime(timestamp) {
|
|
1254
|
+
var date = new Date(timestamp);
|
|
1255
|
+
var now = new Date();
|
|
1256
|
+
var diff = now - date;
|
|
1257
|
+
var minutes = Math.floor(diff / 60000);
|
|
1258
|
+
var hours = Math.floor(diff / 3600000);
|
|
1259
|
+
var days = Math.floor(diff / 86400000);
|
|
1260
|
+
|
|
1261
|
+
if (minutes < 1) return '刚刚';
|
|
1262
|
+
if (minutes < 60) return minutes + '分钟前';
|
|
1263
|
+
if (hours < 24) return hours + '小时前';
|
|
1264
|
+
if (days < 7) return days + '天前';
|
|
1265
|
+
|
|
1266
|
+
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function loadSessions() {
|
|
1270
|
+
var sessionList = document.getElementById('session-list');
|
|
1271
|
+
sessionList.innerHTML = '<div class="session-loading">加载历史会话...</div>';
|
|
1272
|
+
|
|
1273
|
+
fetch('/api/sessions')
|
|
1274
|
+
.then(function(response) { return response.json(); })
|
|
1275
|
+
.then(function(data) {
|
|
1276
|
+
sessions = data;
|
|
1277
|
+
renderSessions();
|
|
1278
|
+
})
|
|
1279
|
+
.catch(function(err) {
|
|
1280
|
+
console.error('[sessions] 加载失败:', err);
|
|
1281
|
+
sessionList.innerHTML = '<div class="session-empty">无法加载历史会话</div>';
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function renderSessions() {
|
|
1286
|
+
var sessionList = document.getElementById('session-list');
|
|
1287
|
+
|
|
1288
|
+
if (sessions.length === 0) {
|
|
1289
|
+
sessionList.innerHTML = '<div class="session-empty">暂无历史会话</div>';
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
sessionList.innerHTML = '';
|
|
1294
|
+
|
|
1295
|
+
sessions.forEach(function(session) {
|
|
1296
|
+
var item = document.createElement('div');
|
|
1297
|
+
item.className = 'session-item';
|
|
1298
|
+
if (session.id === currentSessionId) {
|
|
1299
|
+
item.classList.add('active');
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
var icon = document.createElement('div');
|
|
1303
|
+
icon.className = 'session-icon';
|
|
1304
|
+
|
|
1305
|
+
var info = document.createElement('div');
|
|
1306
|
+
info.className = 'session-info';
|
|
1307
|
+
|
|
1308
|
+
var title = document.createElement('div');
|
|
1309
|
+
title.className = 'session-title';
|
|
1310
|
+
// 使用预览文本,如果没有预览则使用标题
|
|
1311
|
+
title.textContent = session.preview || session.title;
|
|
1312
|
+
|
|
1313
|
+
var meta = document.createElement('div');
|
|
1314
|
+
meta.className = 'session-meta';
|
|
1315
|
+
|
|
1316
|
+
var time = document.createElement('span');
|
|
1317
|
+
time.className = 'session-time';
|
|
1318
|
+
time.textContent = formatTime(session.time_updated);
|
|
1319
|
+
|
|
1320
|
+
var dir = document.createElement('span');
|
|
1321
|
+
dir.className = 'session-dir';
|
|
1322
|
+
dir.textContent = session.directory.replace(/^\/Users\/[^\/]+/, '~');
|
|
1323
|
+
|
|
1324
|
+
meta.appendChild(time);
|
|
1325
|
+
meta.appendChild(dir);
|
|
1326
|
+
info.appendChild(title);
|
|
1327
|
+
info.appendChild(meta);
|
|
1328
|
+
|
|
1329
|
+
item.appendChild(icon);
|
|
1330
|
+
item.appendChild(info);
|
|
1331
|
+
|
|
1332
|
+
item.addEventListener('click', function() {
|
|
1333
|
+
loadSession(session);
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
sessionList.appendChild(item);
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function showSessionDetail() {
|
|
1341
|
+
document.getElementById('session-list-view').classList.add('hidden');
|
|
1342
|
+
document.getElementById('session-detail-view').classList.add('visible');
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function showSessionList() {
|
|
1346
|
+
document.getElementById('session-list-view').classList.remove('hidden');
|
|
1347
|
+
document.getElementById('session-detail-view').classList.remove('visible');
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function loadSession(session) {
|
|
1351
|
+
console.log('[session] 加载会话:', session.title);
|
|
1352
|
+
|
|
1353
|
+
// 保存当前会话数据
|
|
1354
|
+
currentSessionData = session;
|
|
1355
|
+
|
|
1356
|
+
// 切换到详情视图
|
|
1357
|
+
showSessionDetail();
|
|
1358
|
+
|
|
1359
|
+
// 更新详情页标题和元信息
|
|
1360
|
+
document.getElementById('session-detail-title').textContent = session.preview || session.title;
|
|
1361
|
+
document.getElementById('session-detail-time').textContent = formatTime(session.time_updated);
|
|
1362
|
+
document.getElementById('session-detail-dir').textContent = session.directory.replace(/^\/Users\/[^\/]+/, '~');
|
|
1363
|
+
|
|
1364
|
+
var contentDiv = document.getElementById('session-detail-content');
|
|
1365
|
+
contentDiv.innerHTML = '<div class="message-empty">加载消息中...</div>';
|
|
1366
|
+
|
|
1367
|
+
// 获取会话的所有消息
|
|
1368
|
+
fetch('/api/session/' + session.id)
|
|
1369
|
+
.then(function(response) { return response.json(); })
|
|
1370
|
+
.then(function(messages) {
|
|
1371
|
+
console.log('[session] 收到', messages.length, '条消息');
|
|
1372
|
+
|
|
1373
|
+
if (messages.length === 0) {
|
|
1374
|
+
contentDiv.innerHTML = '<div class="message-empty">该会话暂无消息</div>';
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// 渲染消息列表
|
|
1379
|
+
var html = '';
|
|
1380
|
+
messages.forEach(function(msg) {
|
|
1381
|
+
console.log('[render] 消息:', msg);
|
|
1382
|
+
|
|
1383
|
+
var roleClass = msg.role === 'assistant' ? 'message-assistant' : 'message-user';
|
|
1384
|
+
var avatar = msg.role === 'user' ? '👤' : '🤖';
|
|
1385
|
+
var roleName = msg.role === 'user' ? 'User' : 'Assistant';
|
|
1386
|
+
|
|
1387
|
+
html += '<div class="message-item ' + roleClass + '">';
|
|
1388
|
+
html += '<div class="message-avatar">' + avatar + '</div>';
|
|
1389
|
+
html += '<div class="message-content">';
|
|
1390
|
+
html += '<div class="message-header">';
|
|
1391
|
+
html += '<div class="message-role">' + roleName + '</div>';
|
|
1392
|
+
html += '</div>';
|
|
1393
|
+
|
|
1394
|
+
// 显示推理过程(仅 assistant)
|
|
1395
|
+
if (msg.reasoning) {
|
|
1396
|
+
html += '<div class="message-text" style="background: #1a1a0a; border-color: #333;">';
|
|
1397
|
+
html += '<div style="color: #f0ad4e; font-size: 11px; font-weight: 600; margin-bottom: 6px;">💭 思考过程</div>';
|
|
1398
|
+
html += '<div style="color: #bbb;">' + escapeHtml(msg.reasoning) + '</div>';
|
|
1399
|
+
html += '</div>';
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// 显示文本内容
|
|
1403
|
+
if (msg.text) {
|
|
1404
|
+
html += '<div class="message-text">' + escapeHtml(msg.text) + '</div>';
|
|
1405
|
+
} else if (!msg.reasoning && !msg.toolCalls && !msg.toolResults) {
|
|
1406
|
+
html += '<div class="message-text" style="color: #666; font-style: italic;">(无文本内容)</div>';
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// 显示工具调用
|
|
1410
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
1411
|
+
msg.toolCalls.forEach(function(tool) {
|
|
1412
|
+
html += '<div class="message-tool-call">';
|
|
1413
|
+
html += '<div class="message-tool-name">🔧 工具调用: ' + escapeHtml(tool.name || '未知工具') + '</div>';
|
|
1414
|
+
html += '</div>';
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// 显示工具结果
|
|
1419
|
+
if (msg.toolResults && msg.toolResults.length > 0) {
|
|
1420
|
+
msg.toolResults.forEach(function(result) {
|
|
1421
|
+
if (result.text) {
|
|
1422
|
+
var resultText = result.text.length > 200 ? result.text.substring(0, 200) + '...' : result.text;
|
|
1423
|
+
html += '<div class="message-tool-call">';
|
|
1424
|
+
html += '<div class="message-tool-name">📝 工具结果</div>';
|
|
1425
|
+
html += '<div class="message-tool-result">' + escapeHtml(resultText) + '</div>';
|
|
1426
|
+
html += '</div>';
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
html += '</div>';
|
|
1432
|
+
html += '</div>';
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
contentDiv.innerHTML = html;
|
|
1436
|
+
|
|
1437
|
+
// 滚动到底部
|
|
1438
|
+
contentDiv.scrollTop = contentDiv.scrollHeight;
|
|
1439
|
+
})
|
|
1440
|
+
.catch(function(err) {
|
|
1441
|
+
console.error('[session] 加载消息失败:', err);
|
|
1442
|
+
contentDiv.innerHTML = '<div class="message-empty">加载失败: ' + escapeHtml(err.message) + '</div>';
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function escapeHtml(text) {
|
|
1447
|
+
var div = document.createElement('div');
|
|
1448
|
+
div.textContent = text;
|
|
1449
|
+
return div.innerHTML;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function toggleHistoryBar() {
|
|
1453
|
+
historyBarVisible = !historyBarVisible;
|
|
1454
|
+
var historyBar = document.getElementById('session-history-bar');
|
|
1455
|
+
|
|
1456
|
+
if (historyBarVisible) {
|
|
1457
|
+
historyBar.classList.add('visible');
|
|
1458
|
+
loadSessions();
|
|
1459
|
+
} else {
|
|
1460
|
+
historyBar.classList.remove('visible');
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// 绑定历史按钮
|
|
1465
|
+
document.getElementById('history-toggle').addEventListener('click', function() {
|
|
1466
|
+
toggleHistoryBar();
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
// 刷新会话列表
|
|
1470
|
+
document.getElementById('refresh-sessions').addEventListener('click', function(e) {
|
|
1471
|
+
e.stopPropagation();
|
|
1472
|
+
loadSessions();
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
// 关闭历史栏
|
|
1476
|
+
document.getElementById('close-history').addEventListener('click', function(e) {
|
|
1477
|
+
e.stopPropagation();
|
|
1478
|
+
toggleHistoryBar();
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
// 返回到会话列表
|
|
1482
|
+
document.getElementById('back-to-list').addEventListener('click', function(e) {
|
|
1483
|
+
e.stopPropagation();
|
|
1484
|
+
showSessionList();
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
// 恢复会话
|
|
1488
|
+
document.getElementById('restore-session').addEventListener('click', function(e) {
|
|
1489
|
+
e.stopPropagation();
|
|
1490
|
+
if (!currentSessionData) {
|
|
1491
|
+
console.error('[restore] 没有当前会话数据');
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
console.log('[restore] 恢复会话:', currentSessionData.id);
|
|
1496
|
+
|
|
1497
|
+
// 关闭历史栏
|
|
1498
|
+
toggleHistoryBar();
|
|
1499
|
+
|
|
1500
|
+
// 清空终端
|
|
1501
|
+
term.clear();
|
|
1502
|
+
|
|
1503
|
+
// 显示正在恢复的提示
|
|
1504
|
+
term.write('\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m\r\n');
|
|
1505
|
+
term.write('\x1b[1;36m║ \x1b[1;37m正在恢复会话... \x1b[1;36m║\x1b[0m\r\n');
|
|
1506
|
+
term.write('\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m\r\n');
|
|
1507
|
+
term.write('\r\n');
|
|
1508
|
+
|
|
1509
|
+
// 发送恢复会话的请求到服务端
|
|
1510
|
+
if (ws && ws.readyState === 1) {
|
|
1511
|
+
if (currentMode !== 'opencode') {
|
|
1512
|
+
term.write('\x1b[31m错误: 请先切换到 OpenCode 模式\x1b[0m\r\n');
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
term.write('\x1b[33m正在重启 OpenCode 并恢复会话: ' + currentSessionData.id + '\x1b[0m\r\n');
|
|
1517
|
+
term.write('\r\n');
|
|
1518
|
+
|
|
1519
|
+
// 发送恢复请求
|
|
1520
|
+
ws.send(JSON.stringify({
|
|
1521
|
+
type: 'restore',
|
|
1522
|
+
sessionId: currentSessionData.id
|
|
1523
|
+
}));
|
|
1524
|
+
} else {
|
|
1525
|
+
term.write('\x1b[31m错误: WebSocket 未连接\x1b[0m\r\n');
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
|
|
801
1529
|
// 初始化虚拟按键事件
|
|
802
1530
|
setupVirtualKeyEvents();
|
|
803
1531
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-opencode-viewer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/ChrisJason121238/claude-opencode-viewer#readme",
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"better-sqlite3": "^11.8.1",
|
|
36
37
|
"node-pty": "^1.1.0",
|
|
37
38
|
"ws": "^8.19.0"
|
|
38
39
|
},
|
package/server.js
CHANGED
|
@@ -3,10 +3,11 @@ import { createServer } from 'node:http';
|
|
|
3
3
|
import { existsSync, createReadStream } from 'node:fs';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { networkInterfaces, platform, arch } from 'node:os';
|
|
6
|
+
import { networkInterfaces, platform, arch, homedir } from 'node:os';
|
|
7
7
|
import { chmodSync, statSync } from 'node:fs';
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
|
+
import Database from 'better-sqlite3';
|
|
10
11
|
|
|
11
12
|
// 设置进程名为 claude-opencode-viewer
|
|
12
13
|
process.title = 'claude-opencode-viewer';
|
|
@@ -14,6 +15,12 @@ process.title = 'claude-opencode-viewer';
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const PORT = 7008;
|
|
16
17
|
|
|
18
|
+
// OpenCode 数据库路径(支持环境变量覆盖,自动检测 /halo 环境)
|
|
19
|
+
const OPENCODE_DB_PATH = process.env.OPENCODE_DB_PATH || join(
|
|
20
|
+
existsSync('/halo') ? '/halo/.local/share' : (process.env.XDG_DATA_HOME || join(homedir(), '.local/share')),
|
|
21
|
+
'opencode/opencode.db'
|
|
22
|
+
);
|
|
23
|
+
|
|
17
24
|
const MAX_BUFFER = 200000;
|
|
18
25
|
|
|
19
26
|
let ptyModule = null;
|
|
@@ -93,7 +100,7 @@ function findCommand(cmd) {
|
|
|
93
100
|
return cmd;
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
async function spawnProcess(mode) {
|
|
103
|
+
async function spawnProcess(mode, sessionId = null) {
|
|
97
104
|
const pty = await getPty();
|
|
98
105
|
fixSpawnHelperPermissions();
|
|
99
106
|
|
|
@@ -108,6 +115,17 @@ async function spawnProcess(mode) {
|
|
|
108
115
|
}
|
|
109
116
|
} else {
|
|
110
117
|
command = findCommand('opencode');
|
|
118
|
+
// 如果提供了 sessionId,添加 --session 参数
|
|
119
|
+
if (sessionId) {
|
|
120
|
+
args = ['--session', sessionId];
|
|
121
|
+
console.log(`[opencode] 恢复会话: ${sessionId}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const spawnEnv = { ...process.env };
|
|
126
|
+
// 如果 /halo 存在,将 opencode 数据持久化到 NAS
|
|
127
|
+
if (mode === 'opencode' && existsSync('/halo')) {
|
|
128
|
+
spawnEnv.XDG_DATA_HOME = '/halo/.local/share';
|
|
111
129
|
}
|
|
112
130
|
|
|
113
131
|
const proc = pty.spawn(command, args, {
|
|
@@ -115,7 +133,7 @@ async function spawnProcess(mode) {
|
|
|
115
133
|
cols: lastPtyCols,
|
|
116
134
|
rows: lastPtyRows,
|
|
117
135
|
cwd: process.cwd(),
|
|
118
|
-
env:
|
|
136
|
+
env: spawnEnv,
|
|
119
137
|
});
|
|
120
138
|
|
|
121
139
|
proc.onData((data) => {
|
|
@@ -126,11 +144,18 @@ async function spawnProcess(mode) {
|
|
|
126
144
|
dataListeners.forEach(cb => cb(data));
|
|
127
145
|
});
|
|
128
146
|
|
|
129
|
-
proc.onExit(() => {
|
|
147
|
+
proc.onExit(({ exitCode }) => {
|
|
148
|
+
console.log(`[onExit] 进程退出, PID: ${proc.pid}, exitCode: ${exitCode}`);
|
|
130
149
|
if (currentProcess === proc) {
|
|
131
150
|
currentProcess = null;
|
|
132
|
-
exitListeners.forEach(cb => cb(0));
|
|
133
151
|
}
|
|
152
|
+
if (claudeProcess === proc) {
|
|
153
|
+
claudeProcess = null;
|
|
154
|
+
}
|
|
155
|
+
if (opencodeProcess === proc) {
|
|
156
|
+
opencodeProcess = null;
|
|
157
|
+
}
|
|
158
|
+
exitListeners.forEach(cb => cb(exitCode || 0));
|
|
134
159
|
});
|
|
135
160
|
|
|
136
161
|
// 只在初始化时杀死旧进程,switchMode 已经处理了切换时的进程清理
|
|
@@ -214,6 +239,155 @@ function resizePty(cols, rows) {
|
|
|
214
239
|
});
|
|
215
240
|
}
|
|
216
241
|
|
|
242
|
+
// 数据库访问函数
|
|
243
|
+
function getOpenCodeSessions() {
|
|
244
|
+
try {
|
|
245
|
+
if (!existsSync(OPENCODE_DB_PATH)) {
|
|
246
|
+
console.log('[DB] OpenCode 数据库不存在:', OPENCODE_DB_PATH);
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const db = new Database(OPENCODE_DB_PATH, { readonly: true });
|
|
251
|
+
|
|
252
|
+
const sessions = db.prepare(`
|
|
253
|
+
SELECT
|
|
254
|
+
s.id,
|
|
255
|
+
s.title,
|
|
256
|
+
s.directory,
|
|
257
|
+
s.time_created,
|
|
258
|
+
s.time_updated,
|
|
259
|
+
p.name as project_name
|
|
260
|
+
FROM session s
|
|
261
|
+
LEFT JOIN project p ON s.project_id = p.id
|
|
262
|
+
WHERE s.parent_id IS NULL
|
|
263
|
+
AND s.time_archived IS NULL
|
|
264
|
+
ORDER BY s.time_updated DESC
|
|
265
|
+
LIMIT 50
|
|
266
|
+
`).all();
|
|
267
|
+
|
|
268
|
+
// 为每个会话获取第一条用户消息作为预览
|
|
269
|
+
const result = sessions.map(session => {
|
|
270
|
+
// 获取第一条用户消息
|
|
271
|
+
const firstMessage = db.prepare(`
|
|
272
|
+
SELECT id
|
|
273
|
+
FROM message
|
|
274
|
+
WHERE session_id = ?
|
|
275
|
+
AND json_extract(data, '$.role') = 'user'
|
|
276
|
+
ORDER BY time_created ASC
|
|
277
|
+
LIMIT 1
|
|
278
|
+
`).get(session.id);
|
|
279
|
+
|
|
280
|
+
let preview = '';
|
|
281
|
+
if (firstMessage) {
|
|
282
|
+
// 获取该消息的文本 part
|
|
283
|
+
const textPart = db.prepare(`
|
|
284
|
+
SELECT data
|
|
285
|
+
FROM part
|
|
286
|
+
WHERE message_id = ?
|
|
287
|
+
AND json_extract(data, '$.type') = 'text'
|
|
288
|
+
LIMIT 1
|
|
289
|
+
`).get(firstMessage.id);
|
|
290
|
+
|
|
291
|
+
if (textPart) {
|
|
292
|
+
try {
|
|
293
|
+
const partData = JSON.parse(textPart.data);
|
|
294
|
+
if (partData.text) {
|
|
295
|
+
// 截取前80个字符作为预览
|
|
296
|
+
preview = partData.text.length > 80
|
|
297
|
+
? partData.text.substring(0, 80) + '...'
|
|
298
|
+
: partData.text;
|
|
299
|
+
}
|
|
300
|
+
} catch (e) {
|
|
301
|
+
console.error('[DB] 解析 part 失败:', e.message);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
...session,
|
|
308
|
+
preview: preview || session.title
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
db.close();
|
|
313
|
+
return result;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
console.error('[DB] 读取会话失败:', err.message);
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getSessionMessages(sessionId) {
|
|
321
|
+
try {
|
|
322
|
+
if (!existsSync(OPENCODE_DB_PATH)) {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const db = new Database(OPENCODE_DB_PATH, { readonly: true });
|
|
327
|
+
|
|
328
|
+
// 获取消息列表
|
|
329
|
+
const messages = db.prepare(`
|
|
330
|
+
SELECT
|
|
331
|
+
id,
|
|
332
|
+
time_created,
|
|
333
|
+
data
|
|
334
|
+
FROM message
|
|
335
|
+
WHERE session_id = ?
|
|
336
|
+
ORDER BY time_created ASC
|
|
337
|
+
`).all(sessionId);
|
|
338
|
+
|
|
339
|
+
// 为每个消息获取其 parts
|
|
340
|
+
const result = messages.map(msg => {
|
|
341
|
+
const msgData = JSON.parse(msg.data);
|
|
342
|
+
|
|
343
|
+
// 获取该消息的所有 parts
|
|
344
|
+
const parts = db.prepare(`
|
|
345
|
+
SELECT data
|
|
346
|
+
FROM part
|
|
347
|
+
WHERE message_id = ?
|
|
348
|
+
ORDER BY time_created ASC
|
|
349
|
+
`).all(msg.id);
|
|
350
|
+
|
|
351
|
+
const parsedParts = parts.map(p => JSON.parse(p.data));
|
|
352
|
+
|
|
353
|
+
// 提取文本内容
|
|
354
|
+
let text = '';
|
|
355
|
+
let reasoning = '';
|
|
356
|
+
let toolCalls = [];
|
|
357
|
+
let toolResults = [];
|
|
358
|
+
|
|
359
|
+
for (const part of parsedParts) {
|
|
360
|
+
if (part.type === 'text' && part.text) {
|
|
361
|
+
text += part.text;
|
|
362
|
+
} else if (part.type === 'reasoning' && part.text) {
|
|
363
|
+
reasoning += part.text;
|
|
364
|
+
} else if (part.type === 'tool-call' || part.type === 'tool_call') {
|
|
365
|
+
toolCalls.push(part);
|
|
366
|
+
} else if (part.type === 'tool-result' || part.type === 'tool_result') {
|
|
367
|
+
toolResults.push(part);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
id: msg.id,
|
|
373
|
+
time_created: msg.time_created,
|
|
374
|
+
role: msgData.role,
|
|
375
|
+
text: text || undefined,
|
|
376
|
+
reasoning: reasoning || undefined,
|
|
377
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
378
|
+
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
|
379
|
+
metadata: msgData
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
db.close();
|
|
384
|
+
return result;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.error('[DB] 读取消息失败:', err.message);
|
|
387
|
+
return [];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
217
391
|
const server = createServer((req, res) => {
|
|
218
392
|
if (req.url === '/' || req.url === '/index.html') {
|
|
219
393
|
res.writeHead(200, {
|
|
@@ -223,6 +397,30 @@ const server = createServer((req, res) => {
|
|
|
223
397
|
createReadStream(join(__dirname, 'index.html')).pipe(res);
|
|
224
398
|
return;
|
|
225
399
|
}
|
|
400
|
+
|
|
401
|
+
// API: 获取会话列表
|
|
402
|
+
if (req.url === '/api/sessions') {
|
|
403
|
+
res.writeHead(200, {
|
|
404
|
+
'Content-Type': 'application/json',
|
|
405
|
+
'Access-Control-Allow-Origin': '*',
|
|
406
|
+
});
|
|
407
|
+
const sessions = getOpenCodeSessions();
|
|
408
|
+
res.end(JSON.stringify(sessions));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// API: 获取会话消息
|
|
413
|
+
if (req.url?.startsWith('/api/session/')) {
|
|
414
|
+
const sessionId = req.url.split('/').pop();
|
|
415
|
+
res.writeHead(200, {
|
|
416
|
+
'Content-Type': 'application/json',
|
|
417
|
+
'Access-Control-Allow-Origin': '*',
|
|
418
|
+
});
|
|
419
|
+
const messages = getSessionMessages(sessionId);
|
|
420
|
+
res.end(JSON.stringify(messages));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
226
424
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
227
425
|
res.end('Not Found');
|
|
228
426
|
});
|
|
@@ -260,8 +458,20 @@ wss.on('connection', (ws, req) => {
|
|
|
260
458
|
ws.on('message', async (raw) => {
|
|
261
459
|
try {
|
|
262
460
|
const msg = JSON.parse(raw);
|
|
263
|
-
|
|
461
|
+
console.log(`[WS msg] type=${msg.type}, currentProcess=${!!currentProcess}, currentMode=${currentMode}`);
|
|
462
|
+
|
|
264
463
|
if (msg.type === 'input') {
|
|
464
|
+
// 进程已退出时,自动重新启动(参考 cc-viewer 逻辑)
|
|
465
|
+
if (!currentProcess) {
|
|
466
|
+
try {
|
|
467
|
+
console.log(`[respawn] 进程已退出,自动重新启动 ${currentMode}`);
|
|
468
|
+
outputBuffer = '';
|
|
469
|
+
await spawnProcess(currentMode);
|
|
470
|
+
console.log(`[respawn] 重新启动成功, currentProcess=${!!currentProcess}`);
|
|
471
|
+
} catch (e) {
|
|
472
|
+
console.log(`[respawn] 重新启动失败: ${e.message}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
265
475
|
if (activeWs !== ws) {
|
|
266
476
|
activeWs = ws;
|
|
267
477
|
const mSize = getMobileSize();
|
|
@@ -293,6 +503,38 @@ wss.on('connection', (ws, req) => {
|
|
|
293
503
|
}
|
|
294
504
|
}, 100);
|
|
295
505
|
}
|
|
506
|
+
} else if (msg.type === 'restore') {
|
|
507
|
+
// 恢复会话
|
|
508
|
+
if (msg.sessionId && currentMode === 'opencode') {
|
|
509
|
+
console.log(`[restore] 恢复会话: ${msg.sessionId}`);
|
|
510
|
+
|
|
511
|
+
// 杀死当前 opencode 进程
|
|
512
|
+
if (opencodeProcess) {
|
|
513
|
+
try {
|
|
514
|
+
console.log(`[restore] 杀死当前进程 PID: ${opencodeProcess.pid}`);
|
|
515
|
+
opencodeProcess.kill();
|
|
516
|
+
} catch (e) {
|
|
517
|
+
console.log('[restore] 杀死进程失败:', e.message);
|
|
518
|
+
}
|
|
519
|
+
opencodeProcess = null;
|
|
520
|
+
currentProcess = null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 清空输出缓冲
|
|
524
|
+
outputBuffer = '';
|
|
525
|
+
|
|
526
|
+
// 等待进程完全退出
|
|
527
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
528
|
+
|
|
529
|
+
// 启动新的 opencode 进程,传入 session ID
|
|
530
|
+
try {
|
|
531
|
+
await spawnProcess('opencode', msg.sessionId);
|
|
532
|
+
ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId }));
|
|
533
|
+
} catch (e) {
|
|
534
|
+
console.error('[restore] 启动进程失败:', e.message);
|
|
535
|
+
ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
296
538
|
}
|
|
297
539
|
} catch (err) {
|
|
298
540
|
console.error('[WS] Error:', err.message);
|