@zhongqian97-code/ecode 0.5.33 → 0.5.34
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/dist/chunk-GR5MASXF.js +60 -0
- package/dist/chunk-JG2IGHYY.js +46 -0
- package/dist/chunk-O4YFKL3N.js +265 -0
- package/dist/chunk-VM35XIBY.js +1951 -0
- package/dist/index.js +228 -7131
- package/dist/ui-VPHPVIS5.js +2994 -0
- package/dist/web-Y5CK2WBF.js +1870 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1870 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const _ew=process.emitWarning.bind(process);process.emitWarning=function(w,...a){if((w?.message??w)?.includes?.('punycode'))return;_ew(w,...a);};
|
|
3
|
+
import {
|
|
4
|
+
cmdSessionsFork,
|
|
5
|
+
cmdSessionsReplay
|
|
6
|
+
} from "./chunk-GR5MASXF.js";
|
|
7
|
+
import {
|
|
8
|
+
loadJobs,
|
|
9
|
+
removeJob,
|
|
10
|
+
upsertJob
|
|
11
|
+
} from "./chunk-JG2IGHYY.js";
|
|
12
|
+
import {
|
|
13
|
+
deleteSessionFiles,
|
|
14
|
+
findSession,
|
|
15
|
+
listSessions,
|
|
16
|
+
loadMessagesFromJsonl,
|
|
17
|
+
saveConfig
|
|
18
|
+
} from "./chunk-O4YFKL3N.js";
|
|
19
|
+
|
|
20
|
+
// src/web/server.ts
|
|
21
|
+
import Fastify from "fastify";
|
|
22
|
+
import cors from "@fastify/cors";
|
|
23
|
+
import websocket from "@fastify/websocket";
|
|
24
|
+
|
|
25
|
+
// src/web/auth.ts
|
|
26
|
+
import { randomUUID } from "crypto";
|
|
27
|
+
function generateAccessToken() {
|
|
28
|
+
return randomUUID();
|
|
29
|
+
}
|
|
30
|
+
function createAuthHook(token) {
|
|
31
|
+
return function authHook(request, reply, done) {
|
|
32
|
+
var _a;
|
|
33
|
+
const headerToken = request.headers["x-ecode-token"];
|
|
34
|
+
const queryToken = (_a = request.query) == null ? void 0 : _a["token"];
|
|
35
|
+
if (headerToken === token || queryToken === token) {
|
|
36
|
+
done();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
reply.code(401).send({ success: false, error: "Unauthorized" });
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/web/admin-html.ts
|
|
44
|
+
function generateAdminHtml(version) {
|
|
45
|
+
return `<!DOCTYPE html>
|
|
46
|
+
<html lang="zh">
|
|
47
|
+
<head>
|
|
48
|
+
<meta charset="UTF-8">
|
|
49
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
|
50
|
+
<title>ecode web admin</title>
|
|
51
|
+
<style>
|
|
52
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
53
|
+
body {
|
|
54
|
+
font-family: "Courier New", Courier, monospace;
|
|
55
|
+
background: #0d1117;
|
|
56
|
+
color: #c9d1d9;
|
|
57
|
+
height: 100vh;
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
}
|
|
62
|
+
/* \u2500\u2500 Top bar \u2500\u2500 */
|
|
63
|
+
#topbar {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 10px;
|
|
67
|
+
padding: 8px 14px;
|
|
68
|
+
background: #161b22;
|
|
69
|
+
border-bottom: 1px solid #30363d;
|
|
70
|
+
flex-shrink: 0;
|
|
71
|
+
}
|
|
72
|
+
#topbar h1 { font-size: 14px; color: #e6edf3; font-weight: 600; }
|
|
73
|
+
#topbar .version { font-size: 11px; color: #8b949e; background: #21262d;
|
|
74
|
+
padding: 1px 7px; border-radius: 20px; }
|
|
75
|
+
#hamburger {
|
|
76
|
+
display: none; background: none; border: none; color: #c9d1d9;
|
|
77
|
+
font-size: 18px; cursor: pointer; padding: 2px 6px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* \u2500\u2500 Main layout \u2500\u2500 */
|
|
81
|
+
#app {
|
|
82
|
+
display: flex;
|
|
83
|
+
flex: 1;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* \u2500\u2500 Sidebar \u2500\u2500 */
|
|
88
|
+
#sidebar {
|
|
89
|
+
width: 220px;
|
|
90
|
+
flex-shrink: 0;
|
|
91
|
+
background: #161b22;
|
|
92
|
+
border-right: 1px solid #30363d;
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
}
|
|
97
|
+
#sidebar-header {
|
|
98
|
+
padding: 10px 12px;
|
|
99
|
+
border-bottom: 1px solid #30363d;
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
}
|
|
102
|
+
#new-session-btn {
|
|
103
|
+
width: 100%;
|
|
104
|
+
padding: 6px 10px;
|
|
105
|
+
background: #21262d;
|
|
106
|
+
color: #79c0ff;
|
|
107
|
+
border: 1px solid #30363d;
|
|
108
|
+
border-radius: 4px;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
font-family: inherit;
|
|
111
|
+
font-size: 12px;
|
|
112
|
+
text-align: left;
|
|
113
|
+
}
|
|
114
|
+
#new-session-btn:hover { background: #30363d; }
|
|
115
|
+
#session-list {
|
|
116
|
+
flex: 1;
|
|
117
|
+
overflow-y: auto;
|
|
118
|
+
padding: 6px 0;
|
|
119
|
+
}
|
|
120
|
+
.session-item {
|
|
121
|
+
padding: 8px 12px;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
border-left: 2px solid transparent;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
line-height: 1.4;
|
|
126
|
+
}
|
|
127
|
+
.session-item:hover { background: #21262d; }
|
|
128
|
+
.session-item.active {
|
|
129
|
+
background: #21262d;
|
|
130
|
+
border-left-color: #79c0ff;
|
|
131
|
+
}
|
|
132
|
+
.session-item .s-title { color: #c9d1d9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
133
|
+
.session-item .s-id { color: #8b949e; font-size: 10px; }
|
|
134
|
+
.s-delete {
|
|
135
|
+
float: right; margin-top: -20px; background: none; border: none;
|
|
136
|
+
color: #6e7681; cursor: pointer; font-size: 11px; padding: 0 2px; line-height: 1;
|
|
137
|
+
display: none;
|
|
138
|
+
}
|
|
139
|
+
.session-item:hover .s-delete, .session-item.active .s-delete { display: inline; }
|
|
140
|
+
.s-delete:hover { color: #f85149; }
|
|
141
|
+
.sidebar-empty { color: #8b949e; font-size: 12px; font-style: italic; padding: 12px; }
|
|
142
|
+
|
|
143
|
+
/* \u2500\u2500 Chat area \u2500\u2500 */
|
|
144
|
+
#chat-area {
|
|
145
|
+
flex: 1;
|
|
146
|
+
display: flex;
|
|
147
|
+
flex-direction: column;
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
}
|
|
150
|
+
#chat-header {
|
|
151
|
+
padding: 10px 14px;
|
|
152
|
+
border-bottom: 1px solid #30363d;
|
|
153
|
+
background: #161b22;
|
|
154
|
+
flex-shrink: 0;
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 10px;
|
|
158
|
+
}
|
|
159
|
+
#chat-title { font-size: 13px; color: #8b949e; }
|
|
160
|
+
#status-indicator {
|
|
161
|
+
margin-left: auto;
|
|
162
|
+
font-size: 11px;
|
|
163
|
+
color: #8b949e;
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
gap: 5px;
|
|
167
|
+
}
|
|
168
|
+
#status-dot {
|
|
169
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
170
|
+
background: #3fb950;
|
|
171
|
+
}
|
|
172
|
+
#status-dot.thinking { background: #d29922; animation: pulse 1s infinite; }
|
|
173
|
+
#status-dot.error { background: #f85149; }
|
|
174
|
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
|
175
|
+
|
|
176
|
+
/* \u2500\u2500 Messages \u2500\u2500 */
|
|
177
|
+
#messages {
|
|
178
|
+
flex: 1;
|
|
179
|
+
overflow-y: auto;
|
|
180
|
+
padding: 14px;
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
gap: 10px;
|
|
184
|
+
}
|
|
185
|
+
.msg {
|
|
186
|
+
font-size: 13px;
|
|
187
|
+
line-height: 1.6;
|
|
188
|
+
max-width: 100%;
|
|
189
|
+
}
|
|
190
|
+
.msg-header {
|
|
191
|
+
font-size: 11px;
|
|
192
|
+
margin-bottom: 3px;
|
|
193
|
+
font-weight: 600;
|
|
194
|
+
}
|
|
195
|
+
.msg-header.user { color: #3fb950; }
|
|
196
|
+
.msg-header.assistant { color: #c9d1d9; }
|
|
197
|
+
.msg-header.tool { color: #8b949e; }
|
|
198
|
+
.msg-body { color: #c9d1d9; white-space: pre-wrap; word-break: break-word; }
|
|
199
|
+
.msg-body code {
|
|
200
|
+
background: #21262d;
|
|
201
|
+
padding: 1px 5px;
|
|
202
|
+
border-radius: 3px;
|
|
203
|
+
font-family: inherit;
|
|
204
|
+
font-size: 12px;
|
|
205
|
+
}
|
|
206
|
+
.msg-body pre {
|
|
207
|
+
background: #161b22;
|
|
208
|
+
border: 1px solid #30363d;
|
|
209
|
+
border-radius: 4px;
|
|
210
|
+
padding: 10px;
|
|
211
|
+
overflow-x: auto;
|
|
212
|
+
margin: 6px 0;
|
|
213
|
+
}
|
|
214
|
+
.msg-body pre code { background: none; padding: 0; }
|
|
215
|
+
.msg-body strong { color: #e6edf3; }
|
|
216
|
+
.tool-box {
|
|
217
|
+
border: 1px solid #30363d;
|
|
218
|
+
border-radius: 4px;
|
|
219
|
+
font-size: 12px;
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
margin: 4px 0;
|
|
222
|
+
}
|
|
223
|
+
.tool-box-header {
|
|
224
|
+
background: #21262d;
|
|
225
|
+
padding: 4px 10px;
|
|
226
|
+
color: #8b949e;
|
|
227
|
+
font-size: 11px;
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
user-select: none;
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
gap: 6px;
|
|
233
|
+
}
|
|
234
|
+
.tool-box-header:hover { background: #2d333b; }
|
|
235
|
+
.tool-box.collapsed .tool-box-body { display: none; }
|
|
236
|
+
.tool-box-body {
|
|
237
|
+
padding: 8px 10px;
|
|
238
|
+
color: #c9d1d9;
|
|
239
|
+
white-space: pre-wrap;
|
|
240
|
+
word-break: break-word;
|
|
241
|
+
}
|
|
242
|
+
.thinking-block {
|
|
243
|
+
border: 1px solid #21262d;
|
|
244
|
+
border-radius: 4px;
|
|
245
|
+
font-size: 12px;
|
|
246
|
+
padding: 6px 10px;
|
|
247
|
+
margin: 4px 0;
|
|
248
|
+
color: #6e7681;
|
|
249
|
+
font-style: italic;
|
|
250
|
+
white-space: pre-wrap;
|
|
251
|
+
word-break: break-word;
|
|
252
|
+
}
|
|
253
|
+
.cursor-blink::after {
|
|
254
|
+
content: "\u258B";
|
|
255
|
+
animation: blink 1s step-start infinite;
|
|
256
|
+
}
|
|
257
|
+
@keyframes blink { 50% { opacity: 0; } }
|
|
258
|
+
.empty-chat {
|
|
259
|
+
color: #8b949e;
|
|
260
|
+
font-size: 13px;
|
|
261
|
+
font-style: italic;
|
|
262
|
+
text-align: center;
|
|
263
|
+
padding: 40px 20px;
|
|
264
|
+
}
|
|
265
|
+
.ws-status {
|
|
266
|
+
font-size: 11px;
|
|
267
|
+
color: #8b949e;
|
|
268
|
+
text-align: center;
|
|
269
|
+
padding: 4px;
|
|
270
|
+
flex-shrink: 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* \u2500\u2500 Input bar \u2500\u2500 */
|
|
274
|
+
#input-bar {
|
|
275
|
+
border-top: 1px solid #30363d;
|
|
276
|
+
background: #161b22;
|
|
277
|
+
padding: 10px 14px;
|
|
278
|
+
display: flex;
|
|
279
|
+
gap: 8px;
|
|
280
|
+
align-items: flex-end;
|
|
281
|
+
flex-shrink: 0;
|
|
282
|
+
}
|
|
283
|
+
#msg-input {
|
|
284
|
+
flex: 1;
|
|
285
|
+
background: #0d1117;
|
|
286
|
+
border: 1px solid #30363d;
|
|
287
|
+
border-radius: 4px;
|
|
288
|
+
color: #c9d1d9;
|
|
289
|
+
font-family: inherit;
|
|
290
|
+
font-size: 13px;
|
|
291
|
+
padding: 8px 10px;
|
|
292
|
+
resize: none;
|
|
293
|
+
outline: none;
|
|
294
|
+
min-height: 38px;
|
|
295
|
+
max-height: 120px;
|
|
296
|
+
}
|
|
297
|
+
#msg-input:focus { border-color: #58a6ff; }
|
|
298
|
+
#msg-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
299
|
+
#send-btn {
|
|
300
|
+
padding: 8px 14px;
|
|
301
|
+
background: #21262d;
|
|
302
|
+
color: #79c0ff;
|
|
303
|
+
border: 1px solid #30363d;
|
|
304
|
+
border-radius: 4px;
|
|
305
|
+
cursor: pointer;
|
|
306
|
+
font-family: inherit;
|
|
307
|
+
font-size: 13px;
|
|
308
|
+
white-space: nowrap;
|
|
309
|
+
flex-shrink: 0;
|
|
310
|
+
}
|
|
311
|
+
#send-btn:hover:not(:disabled) { background: #30363d; }
|
|
312
|
+
#send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
313
|
+
|
|
314
|
+
/* \u2500\u2500 Approval modal \u2500\u2500 */
|
|
315
|
+
#approval-modal {
|
|
316
|
+
display: none;
|
|
317
|
+
position: fixed;
|
|
318
|
+
inset: 0;
|
|
319
|
+
background: rgba(0,0,0,.65);
|
|
320
|
+
z-index: 100;
|
|
321
|
+
align-items: center;
|
|
322
|
+
justify-content: center;
|
|
323
|
+
}
|
|
324
|
+
#approval-modal.visible { display: flex; }
|
|
325
|
+
#approval-box {
|
|
326
|
+
background: #161b22;
|
|
327
|
+
border: 1px solid #30363d;
|
|
328
|
+
border-radius: 6px;
|
|
329
|
+
width: min(520px, 90vw);
|
|
330
|
+
padding: 20px;
|
|
331
|
+
}
|
|
332
|
+
#approval-box.danger { border-color: #f85149; }
|
|
333
|
+
#approval-title {
|
|
334
|
+
font-size: 13px;
|
|
335
|
+
font-weight: 600;
|
|
336
|
+
color: #e6edf3;
|
|
337
|
+
margin-bottom: 10px;
|
|
338
|
+
}
|
|
339
|
+
#approval-prompt {
|
|
340
|
+
background: #0d1117;
|
|
341
|
+
border: 1px solid #30363d;
|
|
342
|
+
border-radius: 4px;
|
|
343
|
+
padding: 10px;
|
|
344
|
+
font-size: 12px;
|
|
345
|
+
color: #c9d1d9;
|
|
346
|
+
white-space: pre-wrap;
|
|
347
|
+
word-break: break-word;
|
|
348
|
+
max-height: 200px;
|
|
349
|
+
overflow-y: auto;
|
|
350
|
+
margin-bottom: 14px;
|
|
351
|
+
}
|
|
352
|
+
#approval-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
|
353
|
+
#deny-btn {
|
|
354
|
+
padding: 6px 14px;
|
|
355
|
+
background: none;
|
|
356
|
+
border: 1px solid #30363d;
|
|
357
|
+
color: #c9d1d9;
|
|
358
|
+
border-radius: 4px;
|
|
359
|
+
cursor: pointer;
|
|
360
|
+
font-family: inherit;
|
|
361
|
+
font-size: 13px;
|
|
362
|
+
}
|
|
363
|
+
#deny-btn:hover { background: #21262d; }
|
|
364
|
+
#approve-btn {
|
|
365
|
+
padding: 6px 14px;
|
|
366
|
+
background: #1a4731;
|
|
367
|
+
border: 1px solid #3fb950;
|
|
368
|
+
color: #3fb950;
|
|
369
|
+
border-radius: 4px;
|
|
370
|
+
cursor: pointer;
|
|
371
|
+
font-family: inherit;
|
|
372
|
+
font-size: 13px;
|
|
373
|
+
}
|
|
374
|
+
#approve-btn:hover { background: #2ea043; color: #fff; }
|
|
375
|
+
|
|
376
|
+
/* \u2500\u2500 Config & upgrade modals \u2500\u2500 */
|
|
377
|
+
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.7); z-index:100; align-items:center; justify-content:center; }
|
|
378
|
+
.modal-overlay.open { display:flex; }
|
|
379
|
+
.modal { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:24px; width:min(480px,90vw); max-height:80vh; overflow-y:auto; }
|
|
380
|
+
.modal h3 { color:#e6edf3; margin-bottom:16px; font-size:14px; }
|
|
381
|
+
.form-row { margin-bottom:12px; }
|
|
382
|
+
.form-row label { display:block; font-size:12px; color:#8b949e; margin-bottom:4px; }
|
|
383
|
+
.form-row input, .form-row textarea { width:100%; background:#0d1117; border:1px solid #30363d; border-radius:4px; color:#c9d1d9; font-family:monospace; font-size:13px; padding:6px 8px; }
|
|
384
|
+
.form-row textarea { resize:vertical; min-height:60px; }
|
|
385
|
+
.btn { padding:6px 14px; border-radius:4px; border:1px solid #30363d; cursor:pointer; font-family:monospace; font-size:13px; }
|
|
386
|
+
.btn-primary { background:#1f6feb; color:#fff; border-color:#1f6feb; }
|
|
387
|
+
.btn-danger { background:#da3633; color:#fff; border-color:#da3633; }
|
|
388
|
+
.modal-footer { display:flex; gap:8px; justify-content:flex-end; margin-top:16px; }
|
|
389
|
+
#upgrade-output { display:none; background:#0d1117; border:1px solid #30363d; border-radius:4px; padding:8px; font-family:monospace; font-size:12px; color:#c9d1d9; white-space:pre-wrap; max-height:200px; overflow-y:auto; margin-top:8px; }
|
|
390
|
+
|
|
391
|
+
/* \u2500\u2500 Syntax highlight \u2500\u2500 */
|
|
392
|
+
.kw { color: #ff7b72; }
|
|
393
|
+
.str { color: #a5d6ff; }
|
|
394
|
+
.cmt { color: #8b949e; font-style: italic; }
|
|
395
|
+
.num { color: #79c0ff; }
|
|
396
|
+
.fn { color: #d2a8ff; }
|
|
397
|
+
|
|
398
|
+
/* \u2500\u2500 Toast notifications \u2500\u2500 */
|
|
399
|
+
#toast-container {
|
|
400
|
+
position: fixed;
|
|
401
|
+
top: 16px;
|
|
402
|
+
right: 16px;
|
|
403
|
+
z-index: 200;
|
|
404
|
+
display: flex;
|
|
405
|
+
flex-direction: column;
|
|
406
|
+
gap: 8px;
|
|
407
|
+
pointer-events: none;
|
|
408
|
+
}
|
|
409
|
+
.toast {
|
|
410
|
+
padding: 10px 16px;
|
|
411
|
+
border-radius: 4px;
|
|
412
|
+
font-size: 13px;
|
|
413
|
+
max-width: 360px;
|
|
414
|
+
background: #21262d;
|
|
415
|
+
color: #c9d1d9;
|
|
416
|
+
border: 1px solid #30363d;
|
|
417
|
+
pointer-events: auto;
|
|
418
|
+
animation: toast-in .2s ease;
|
|
419
|
+
display: flex;
|
|
420
|
+
align-items: flex-start;
|
|
421
|
+
gap: 10px;
|
|
422
|
+
}
|
|
423
|
+
.toast-error {
|
|
424
|
+
background: #2d1b1b;
|
|
425
|
+
color: #ff7b72;
|
|
426
|
+
border-color: #f85149;
|
|
427
|
+
}
|
|
428
|
+
.toast-success {
|
|
429
|
+
background: #1a2d1a;
|
|
430
|
+
color: #3fb950;
|
|
431
|
+
border-color: #2ea043;
|
|
432
|
+
}
|
|
433
|
+
.toast-close {
|
|
434
|
+
background: none;
|
|
435
|
+
border: none;
|
|
436
|
+
color: inherit;
|
|
437
|
+
font-size: 16px;
|
|
438
|
+
line-height: 1;
|
|
439
|
+
cursor: pointer;
|
|
440
|
+
padding: 0;
|
|
441
|
+
opacity: 0.6;
|
|
442
|
+
flex-shrink: 0;
|
|
443
|
+
margin-left: auto;
|
|
444
|
+
}
|
|
445
|
+
.toast-close:hover { opacity: 1; }
|
|
446
|
+
@keyframes toast-in {
|
|
447
|
+
from { opacity: 0; transform: translateX(20px); }
|
|
448
|
+
to { opacity: 1; transform: translateX(0); }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* \u2500\u2500 Skill dropdown \u2500\u2500 */
|
|
452
|
+
#skill-dropdown .skill-item {
|
|
453
|
+
padding: 8px 12px;
|
|
454
|
+
cursor: pointer;
|
|
455
|
+
border-bottom: 1px solid #30363d;
|
|
456
|
+
display: flex;
|
|
457
|
+
gap: 8px;
|
|
458
|
+
align-items: baseline;
|
|
459
|
+
}
|
|
460
|
+
#skill-dropdown .skill-item:last-child { border-bottom: none; }
|
|
461
|
+
#skill-dropdown .skill-item:hover,
|
|
462
|
+
#skill-dropdown .skill-item.selected { background: #2d333b; }
|
|
463
|
+
#skill-dropdown .skill-name { color: #79c0ff; font-weight: 600; font-size: 0.9em; }
|
|
464
|
+
#skill-dropdown .skill-desc { color: #8b949e; font-size: 0.8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; }
|
|
465
|
+
|
|
466
|
+
/* \u2500\u2500 Mobile \u2500\u2500 */
|
|
467
|
+
@media (max-width: 600px) {
|
|
468
|
+
#hamburger { display: block; }
|
|
469
|
+
#sidebar {
|
|
470
|
+
position: fixed;
|
|
471
|
+
top: 0; left: 0; bottom: 0;
|
|
472
|
+
z-index: 50;
|
|
473
|
+
transform: translateX(-100%);
|
|
474
|
+
transition: transform .2s ease;
|
|
475
|
+
}
|
|
476
|
+
#sidebar.open { transform: translateX(0); }
|
|
477
|
+
|
|
478
|
+
/* Fix input bar on mobile */
|
|
479
|
+
#input-bar {
|
|
480
|
+
position: fixed;
|
|
481
|
+
bottom: 0;
|
|
482
|
+
left: 0;
|
|
483
|
+
right: 0;
|
|
484
|
+
bottom: env(safe-area-inset-bottom, 0px);
|
|
485
|
+
z-index: 40;
|
|
486
|
+
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* Add bottom padding to chat so messages don't hide behind fixed input */
|
|
490
|
+
#messages {
|
|
491
|
+
padding-bottom: 80px;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
</style>
|
|
495
|
+
</head>
|
|
496
|
+
<body>
|
|
497
|
+
<div id="topbar">
|
|
498
|
+
<button id="hamburger" aria-label="Toggle sidebar">\u2630</button>
|
|
499
|
+
<h1>\u26A1 ecode web admin</h1>
|
|
500
|
+
<span class="version">v${version}</span>
|
|
501
|
+
<span id="topbar-model" class="version" style="display:none"></span>
|
|
502
|
+
<button id="config-btn" class="btn">\u914D\u7F6E</button>
|
|
503
|
+
<button id="upgrade-btn" class="btn">\u5347\u7EA7</button>
|
|
504
|
+
<button id="toggle-tools-btn" class="btn">\u5DE5\u5177 \u25BE</button>
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
<div id="app">
|
|
508
|
+
<!-- Left: session sidebar -->
|
|
509
|
+
<div id="sidebar">
|
|
510
|
+
<div id="sidebar-header">
|
|
511
|
+
<button id="new-session-btn">\uFF0B \u65B0\u5EFA\u4F1A\u8BDD</button>
|
|
512
|
+
</div>
|
|
513
|
+
<div id="session-list">
|
|
514
|
+
<div class="sidebar-empty">\u52A0\u8F7D\u4E2D\u2026</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<!-- Right: chat area -->
|
|
519
|
+
<div id="chat-area">
|
|
520
|
+
<div id="chat-header">
|
|
521
|
+
<span id="chat-title">\u9009\u62E9\u6216\u65B0\u5EFA\u4F1A\u8BDD</span>
|
|
522
|
+
<div id="status-indicator">
|
|
523
|
+
<div id="status-dot"></div>
|
|
524
|
+
<span id="status-text">idle</span>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
<div id="messages">
|
|
528
|
+
<div class="empty-chat">\u2190 \u4ECE\u5DE6\u4FA7\u9009\u62E9\u4F1A\u8BDD\uFF0C\u6216\u70B9\u51FB"\u65B0\u5EFA\u4F1A\u8BDD"\u5F00\u59CB</div>
|
|
529
|
+
</div>
|
|
530
|
+
<div id="ws-status" class="ws-status"></div>
|
|
531
|
+
<div id="skill-dropdown" style="display:none;position:fixed;bottom:60px;left:14px;right:14px;max-height:200px;overflow-y:auto;background:#1c2128;border:1px solid #30363d;border-radius:6px;z-index:60;"></div>
|
|
532
|
+
<div id="input-bar">
|
|
533
|
+
<textarea id="msg-input" rows="1" placeholder="\u8F93\u5165\u6D88\u606F\uFF0C\u6309 Enter \u53D1\u9001\uFF08Shift+Enter \u6362\u884C\uFF09\u2026" disabled></textarea>
|
|
534
|
+
<button id="send-btn" disabled>\u53D1\u9001</button>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
<!-- Config modal -->
|
|
540
|
+
<div id="config-modal" class="modal-overlay">
|
|
541
|
+
<div class="modal">
|
|
542
|
+
<h3>\u2699 \u914D\u7F6E</h3>
|
|
543
|
+
<div class="form-row"><label>Model</label><input id="cfg-model" name="model" type="text" /></div>
|
|
544
|
+
<div class="form-row"><label>Base URL</label><input id="cfg-baseurl" name="baseUrl" type="text" /></div>
|
|
545
|
+
<div class="form-row"><label>API Key</label><input id="cfg-apikey" name="apiKey" type="password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" /></div>
|
|
546
|
+
<div class="form-row"><label>Log Dir</label><input id="cfg-logdir" name="logDir" type="text" /></div>
|
|
547
|
+
<div class="form-row"><label>System Prompt</label><textarea id="cfg-systemprompt"></textarea></div>
|
|
548
|
+
<div class="modal-footer">
|
|
549
|
+
<button id="cancel-config" class="btn">\u53D6\u6D88</button>
|
|
550
|
+
<button id="save-config" class="btn btn-primary">\u4FDD\u5B58</button>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
<!-- Upgrade modal -->
|
|
556
|
+
<div id="upgrade-modal" class="modal-overlay">
|
|
557
|
+
<div class="modal">
|
|
558
|
+
<h3>\u2B06 \u5347\u7EA7 ecode</h3>
|
|
559
|
+
<div id="upgrade-status">\u6B63\u5728\u68C0\u67E5\u7248\u672C\u2026</div>
|
|
560
|
+
<div id="upgrade-output"></div>
|
|
561
|
+
<div class="modal-footer">
|
|
562
|
+
<button id="cancel-upgrade" class="btn">\u5173\u95ED</button>
|
|
563
|
+
<button id="confirm-upgrade" class="btn btn-primary" disabled>\u5347\u7EA7</button>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
<!-- Toast notifications -->
|
|
569
|
+
<div id="toast-container"></div>
|
|
570
|
+
|
|
571
|
+
<!-- Bash approval modal -->
|
|
572
|
+
<div id="approval-modal" role="dialog" aria-modal="true">
|
|
573
|
+
<div id="approval-box">
|
|
574
|
+
<div id="approval-title">\u26A0 \u8BF7\u6C42 Bash \u6267\u884C\u6743\u9650</div>
|
|
575
|
+
<pre id="approval-prompt"></pre>
|
|
576
|
+
<div id="approval-actions">
|
|
577
|
+
<button id="deny-btn">\u62D2\u7EDD</button>
|
|
578
|
+
<button id="approve-btn">\u5141\u8BB8</button>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
<script>
|
|
584
|
+
// \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
585
|
+
const state = {
|
|
586
|
+
token: new URLSearchParams(location.search).get('token') || '',
|
|
587
|
+
sessions: [],
|
|
588
|
+
activeSessionId: null,
|
|
589
|
+
messages: [],
|
|
590
|
+
ws: null,
|
|
591
|
+
pendingApproval: null,
|
|
592
|
+
status: 'idle', // idle | thinking | tool_calling | awaiting_confirm
|
|
593
|
+
wsRetries: 0,
|
|
594
|
+
streamingMsgEl: null, // DOM element currently receiving delta tokens
|
|
595
|
+
showTools: true,
|
|
596
|
+
skills: [], // cached skills from /api/skills
|
|
597
|
+
skillsLoaded: false,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// \u2500\u2500 Toast \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
601
|
+
function showToast(msg, type) {
|
|
602
|
+
const container = document.getElementById('toast-container');
|
|
603
|
+
if (!container) return;
|
|
604
|
+
const el = document.createElement('div');
|
|
605
|
+
el.className = 'toast toast-' + (type || 'info');
|
|
606
|
+
const textSpan = document.createElement('span');
|
|
607
|
+
textSpan.textContent = msg;
|
|
608
|
+
el.appendChild(textSpan);
|
|
609
|
+
if (type === 'error') {
|
|
610
|
+
// \u9519\u8BEF Toast \u6301\u4E45\u5316\uFF1A\u4E0D\u81EA\u52A8\u6D88\u5931\uFF0C\u63D0\u4F9B\u5173\u95ED\u6309\u94AE
|
|
611
|
+
const closeBtn = document.createElement('button');
|
|
612
|
+
closeBtn.className = 'toast-close';
|
|
613
|
+
closeBtn.textContent = '\xD7';
|
|
614
|
+
closeBtn.setAttribute('aria-label', '\u5173\u95ED');
|
|
615
|
+
closeBtn.onclick = () => el.remove();
|
|
616
|
+
el.appendChild(closeBtn);
|
|
617
|
+
} else {
|
|
618
|
+
setTimeout(() => el.remove(), 4000);
|
|
619
|
+
}
|
|
620
|
+
container.appendChild(el);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
624
|
+
function apiUrl(path) {
|
|
625
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
626
|
+
return path + sep + 'token=' + encodeURIComponent(state.token);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function apiFetch(path, opts = {}) {
|
|
630
|
+
const url = apiUrl(path);
|
|
631
|
+
const res = await fetch(url, opts);
|
|
632
|
+
if (!res.ok) {
|
|
633
|
+
let msg = res.status + ' ' + res.statusText;
|
|
634
|
+
try { const err = await res.json(); if (err.error) msg = err.error; } catch {}
|
|
635
|
+
throw new Error(msg);
|
|
636
|
+
}
|
|
637
|
+
return res.json();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function wsUrl(sessionId) {
|
|
641
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
642
|
+
return proto + '//' + location.host + '/api/ws/sessions/' + sessionId + '?token=' + encodeURIComponent(state.token);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// \u2500\u2500 Code highlighting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
646
|
+
function highlightCode(code) {
|
|
647
|
+
let s = escHtml(code);
|
|
648
|
+
// keywords
|
|
649
|
+
s = s.replace(/\\b(function|const|let|var|return|if|else|for|while|class|import|export|from|async|await|new|this|typeof|instanceof|null|undefined|true|false)\\b/g, '<span class="kw">$1</span>');
|
|
650
|
+
// strings (single, double \u2014 after HTML escaping, quotes appear as ' or ")
|
|
651
|
+
s = s.replace(/('[^&#]*'|"[^&]*")/g, '<span class="str">$1</span>');
|
|
652
|
+
// comments
|
|
653
|
+
s = s.replace(/(\\/\\/[^\\n]*)/g, '<span class="cmt">$1</span>');
|
|
654
|
+
// numbers
|
|
655
|
+
s = s.replace(/\\b(\\d+\\.?\\d*)\\b/g, '<span class="num">$1</span>');
|
|
656
|
+
return s;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Basic markdown \u2192 HTML (no libs)
|
|
660
|
+
function renderMarkdown(text) {
|
|
661
|
+
// Escape HTML first
|
|
662
|
+
let s = text
|
|
663
|
+
.replace(/&/g, '&')
|
|
664
|
+
.replace(/</g, '<')
|
|
665
|
+
.replace(/>/g, '>');
|
|
666
|
+
// Code blocks (use highlightCode for syntax coloring)
|
|
667
|
+
s = s.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, (_, code) =>
|
|
668
|
+
'<pre><code>' + highlightCode(code.trim()) + '</code></pre>');
|
|
669
|
+
// Inline code
|
|
670
|
+
s = s.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
|
|
671
|
+
// Bold
|
|
672
|
+
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
673
|
+
// Paragraph breaks
|
|
674
|
+
s = s.replace(/\\n\\n+/g, '</p><p>');
|
|
675
|
+
return '<p>' + s + '</p>';
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// \u2500\u2500 Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
679
|
+
function setStatus(status) {
|
|
680
|
+
state.status = status;
|
|
681
|
+
const dot = document.getElementById('status-dot');
|
|
682
|
+
const txt = document.getElementById('status-text');
|
|
683
|
+
dot.className = '';
|
|
684
|
+
if (status === 'idle') {
|
|
685
|
+
dot.style.background = '#3fb950';
|
|
686
|
+
txt.textContent = 'idle';
|
|
687
|
+
} else if (status === 'thinking') {
|
|
688
|
+
dot.className = 'thinking';
|
|
689
|
+
dot.style.background = '';
|
|
690
|
+
txt.textContent = 'thinking\u2026';
|
|
691
|
+
} else if (status === 'tool_calling') {
|
|
692
|
+
dot.className = 'thinking';
|
|
693
|
+
dot.style.background = '';
|
|
694
|
+
txt.textContent = 'tool\u2026';
|
|
695
|
+
} else if (status === 'awaiting_confirm') {
|
|
696
|
+
dot.style.background = '#d29922';
|
|
697
|
+
txt.textContent = 'awaiting approval';
|
|
698
|
+
}
|
|
699
|
+
const inputEl = document.getElementById('msg-input');
|
|
700
|
+
const sendEl = document.getElementById('send-btn');
|
|
701
|
+
const canType = status === 'idle' && state.activeSessionId;
|
|
702
|
+
inputEl.disabled = !canType;
|
|
703
|
+
sendEl.disabled = !canType;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
707
|
+
function renderSidebar() {
|
|
708
|
+
const listEl = document.getElementById('session-list');
|
|
709
|
+
if (!state.sessions.length) {
|
|
710
|
+
listEl.innerHTML = '<div class="sidebar-empty">\u6682\u65E0\u4F1A\u8BDD</div>';
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
listEl.innerHTML = state.sessions.map(s => {
|
|
714
|
+
const active = s.id === state.activeSessionId ? ' active' : '';
|
|
715
|
+
const title = (s.title || '(\u65E0\u6807\u9898)').replace(/</g, '<');
|
|
716
|
+
const id = s.id.slice(0, 8);
|
|
717
|
+
return '<div class="session-item' + active + '" data-id="' + s.id + '">' +
|
|
718
|
+
'<button class="s-delete" data-sid="' + s.id + '" title="\u5220\u9664\u4F1A\u8BDD">\u2715</button>' +
|
|
719
|
+
'<div class="s-title">' + title + '</div>' +
|
|
720
|
+
'<div class="s-id">' + id + '\u2026</div>' +
|
|
721
|
+
'</div>';
|
|
722
|
+
}).join('');
|
|
723
|
+
listEl.querySelectorAll('.session-item').forEach(el => {
|
|
724
|
+
el.addEventListener('click', (e) => {
|
|
725
|
+
if (e.target.classList.contains('s-delete')) return;
|
|
726
|
+
selectSession(el.dataset.id);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
listEl.querySelectorAll('.s-delete').forEach(btn => {
|
|
730
|
+
btn.addEventListener('click', (e) => {
|
|
731
|
+
e.stopPropagation();
|
|
732
|
+
deleteSession(btn.dataset.sid);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function deleteSession(id) {
|
|
738
|
+
if (!confirm('\u786E\u8BA4\u5220\u9664\u8BE5\u4F1A\u8BDD\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002')) return;
|
|
739
|
+
try {
|
|
740
|
+
await apiFetch('/api/sessions/' + id, { method: 'DELETE' });
|
|
741
|
+
state.sessions = state.sessions.filter(s => s.id !== id);
|
|
742
|
+
if (state.activeSessionId === id) {
|
|
743
|
+
state.activeSessionId = null;
|
|
744
|
+
document.getElementById('messages').innerHTML =
|
|
745
|
+
'<div class="empty-chat">\u2190 \u4ECE\u5DE6\u4FA7\u9009\u62E9\u4F1A\u8BDD\uFF0C\u6216\u70B9\u51FB"\u65B0\u5EFA\u4F1A\u8BDD"\u5F00\u59CB</div>';
|
|
746
|
+
document.getElementById('chat-title').textContent = '\u9009\u62E9\u6216\u65B0\u5EFA\u4F1A\u8BDD';
|
|
747
|
+
if (state.ws) { state.ws.onclose = null; state.ws.close(); state.ws = null; }
|
|
748
|
+
setStatus('idle');
|
|
749
|
+
}
|
|
750
|
+
renderSidebar();
|
|
751
|
+
showToast('\u4F1A\u8BDD\u5DF2\u5220\u9664', 'success');
|
|
752
|
+
} catch (e) {
|
|
753
|
+
showToast('\u5220\u9664\u5931\u8D25: ' + e.message, 'error');
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function loadSessions() {
|
|
758
|
+
try {
|
|
759
|
+
const data = await apiFetch('/api/sessions');
|
|
760
|
+
state.sessions = data.sessions || data.data || data || [];
|
|
761
|
+
renderSidebar();
|
|
762
|
+
} catch (e) {
|
|
763
|
+
document.getElementById('session-list').innerHTML =
|
|
764
|
+
'<div class="sidebar-empty" style="color:#f85149">\u52A0\u8F7D\u5931\u8D25</div>';
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// \u2500\u2500 Messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
769
|
+
function clearMessages() {
|
|
770
|
+
document.getElementById('messages').innerHTML =
|
|
771
|
+
'<div class="empty-chat">\u2190 \u52A0\u8F7D\u4F1A\u8BDD\u4E2D\u2026</div>';
|
|
772
|
+
state.messages = [];
|
|
773
|
+
state.streamingMsgEl = null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function loadMessages(sessionId) {
|
|
777
|
+
try {
|
|
778
|
+
const sep = '/api/sessions/' + sessionId + '/messages?token=' + encodeURIComponent(state.token);
|
|
779
|
+
const res = await fetch(sep);
|
|
780
|
+
if (!res.ok) return;
|
|
781
|
+
const text = await res.text();
|
|
782
|
+
const lines = text.split('\\n').filter(l => l.trim());
|
|
783
|
+
const msgsEl = document.getElementById('messages');
|
|
784
|
+
msgsEl.innerHTML = '';
|
|
785
|
+
for (const line of lines) {
|
|
786
|
+
try {
|
|
787
|
+
const m = JSON.parse(line);
|
|
788
|
+
if (m.content) {
|
|
789
|
+
appendMessage(m.role === 'user' ? 'user' : 'assistant', renderMarkdown(m.content));
|
|
790
|
+
}
|
|
791
|
+
} catch { /* skip malformed lines */ }
|
|
792
|
+
}
|
|
793
|
+
if (!lines.length) {
|
|
794
|
+
msgsEl.innerHTML = '<div class="empty-chat">\u6682\u65E0\u5386\u53F2\u6D88\u606F\uFF0C\u5F00\u59CB\u5BF9\u8BDD\u5427</div>';
|
|
795
|
+
}
|
|
796
|
+
} catch (e) {
|
|
797
|
+
// silently ignore \u2014 session might have no log file yet
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function appendMessage(role, htmlContent, opts) {
|
|
802
|
+
const msgsEl = document.getElementById('messages');
|
|
803
|
+
// Remove empty-chat placeholder
|
|
804
|
+
const placeholder = msgsEl.querySelector('.empty-chat');
|
|
805
|
+
if (placeholder) placeholder.remove();
|
|
806
|
+
|
|
807
|
+
const msgEl = document.createElement('div');
|
|
808
|
+
msgEl.className = 'msg';
|
|
809
|
+
const prefix = role === 'user' ? '[user]' : role === 'tool' ? '[tool]' : '[assistant]';
|
|
810
|
+
msgEl.innerHTML =
|
|
811
|
+
'<div class="msg-header ' + role + '">' + prefix + '</div>' +
|
|
812
|
+
'<div class="msg-body">' + htmlContent + '</div>';
|
|
813
|
+
if (opts && opts.streaming) {
|
|
814
|
+
msgEl.querySelector('.msg-body').classList.add('cursor-blink');
|
|
815
|
+
}
|
|
816
|
+
msgsEl.appendChild(msgEl);
|
|
817
|
+
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
818
|
+
return msgEl;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function finalizeStreamingMsg() {
|
|
822
|
+
if (state.streamingMsgEl) {
|
|
823
|
+
const body = state.streamingMsgEl.querySelector('.msg-body');
|
|
824
|
+
if (body) body.classList.remove('cursor-blink');
|
|
825
|
+
state.streamingMsgEl = null;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// \u2500\u2500 WebSocket \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
830
|
+
function connectWs(sessionId) {
|
|
831
|
+
if (state.ws) {
|
|
832
|
+
state.ws.onclose = null;
|
|
833
|
+
state.ws.close();
|
|
834
|
+
state.ws = null;
|
|
835
|
+
}
|
|
836
|
+
state.wsRetries = 0;
|
|
837
|
+
openWs(sessionId);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function openWs(sessionId) {
|
|
841
|
+
const url = wsUrl(sessionId);
|
|
842
|
+
const ws = new WebSocket(url);
|
|
843
|
+
state.ws = ws;
|
|
844
|
+
|
|
845
|
+
document.getElementById('ws-status').textContent = '\u8FDE\u63A5\u4E2D\u2026';
|
|
846
|
+
|
|
847
|
+
ws.onopen = () => {
|
|
848
|
+
document.getElementById('ws-status').textContent = '';
|
|
849
|
+
state.wsRetries = 0;
|
|
850
|
+
setStatus('idle');
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
ws.onmessage = (evt) => {
|
|
854
|
+
let msg;
|
|
855
|
+
try { msg = JSON.parse(evt.data); } catch { return; }
|
|
856
|
+
handleWsEvent(msg);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
ws.onerror = () => {
|
|
860
|
+
document.getElementById('ws-status').textContent = 'WebSocket \u9519\u8BEF';
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
ws.onclose = (evt) => {
|
|
864
|
+
// 4404 = \u4F1A\u8BDD\u4E0D\u5728\u5185\u5B58\u4E2D\uFF08\u5DF2\u7531 resume API \u5904\u7406\uFF09\uFF0C\u4E0D\u9700\u8981\u91CD\u8BD5
|
|
865
|
+
if (evt.code === 4404) {
|
|
866
|
+
document.getElementById('ws-status').textContent = '';
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
document.getElementById('ws-status').textContent = 'WebSocket \u5DF2\u65AD\u5F00';
|
|
870
|
+
finalizeStreamingMsg();
|
|
871
|
+
if (state.activeSessionId === sessionId && state.wsRetries < 5) {
|
|
872
|
+
state.wsRetries++;
|
|
873
|
+
const wsRetryDelay = Math.min(1000 * Math.pow(2, state.wsRetries), 30000);
|
|
874
|
+
document.getElementById('ws-status').textContent =
|
|
875
|
+
'\u91CD\u8FDE\u4E2D\u2026 (' + state.wsRetries + '/5\uFF0C' + (wsRetryDelay/1000).toFixed(0) + 's\u540E)';
|
|
876
|
+
setTimeout(() => openWs(sessionId), wsRetryDelay);
|
|
877
|
+
} else if (state.wsRetries >= 5) {
|
|
878
|
+
document.getElementById('ws-status').textContent = '\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u5237\u65B0\u9875\u9762';
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function handleWsEvent(msg) {
|
|
884
|
+
const type = msg.type || msg.event;
|
|
885
|
+
if (type === 'message.delta') {
|
|
886
|
+
const token = msg.text || '';
|
|
887
|
+
if (!state.streamingMsgEl) {
|
|
888
|
+
state.streamingMsgEl = appendMessage('assistant', escHtml(token), { streaming: true });
|
|
889
|
+
} else {
|
|
890
|
+
const body = state.streamingMsgEl.querySelector('.msg-body');
|
|
891
|
+
body.textContent += token;
|
|
892
|
+
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
|
|
893
|
+
}
|
|
894
|
+
setStatus('thinking');
|
|
895
|
+
} else if (type === 'message.completed') {
|
|
896
|
+
finalizeStreamingMsg();
|
|
897
|
+
setStatus('idle');
|
|
898
|
+
} else if (type === 'tool.started') {
|
|
899
|
+
const name = msg.toolName || 'tool';
|
|
900
|
+
const msgsEl = document.getElementById('messages');
|
|
901
|
+
const placeholder = msgsEl.querySelector('.empty-chat');
|
|
902
|
+
if (placeholder) placeholder.remove();
|
|
903
|
+
const boxEl = document.createElement('div');
|
|
904
|
+
boxEl.className = 'tool-box';
|
|
905
|
+
boxEl.setAttribute('data-tool-id', msg.callId || name);
|
|
906
|
+
boxEl.innerHTML =
|
|
907
|
+
'<div class="tool-box-header" onclick="toggleToolBox(this)">' +
|
|
908
|
+
'<span class="tb-indicator">\u25BC</span>' +
|
|
909
|
+
'<span class="tb-name">\u2699 ' + escHtml(name) + '</span>' +
|
|
910
|
+
'</div>' +
|
|
911
|
+
'<div class="tool-box-body">\u2026</div>';
|
|
912
|
+
if (!state.showTools) boxEl.style.display = 'none';
|
|
913
|
+
msgsEl.appendChild(boxEl);
|
|
914
|
+
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
915
|
+
setStatus('tool_calling');
|
|
916
|
+
} else if (type === 'tool.completed') {
|
|
917
|
+
const toolId = msg.callId || '';
|
|
918
|
+
const boxEl = document.querySelector('[data-tool-id="' + toolId + '"]');
|
|
919
|
+
if (boxEl) {
|
|
920
|
+
const body = boxEl.querySelector('.tool-box-body');
|
|
921
|
+
body.textContent = msg.output || '';
|
|
922
|
+
const nameEl = boxEl.querySelector('.tb-name');
|
|
923
|
+
if (nameEl) nameEl.textContent = '\u2713 ' + (msg.toolName || toolId);
|
|
924
|
+
}
|
|
925
|
+
setStatus('idle');
|
|
926
|
+
} else if (type === 'approval.requested') {
|
|
927
|
+
state.pendingApproval = msg;
|
|
928
|
+
showApprovalModal(msg);
|
|
929
|
+
setStatus('awaiting_confirm');
|
|
930
|
+
} else if (type === 'approval.resolved') {
|
|
931
|
+
hideApprovalModal();
|
|
932
|
+
state.pendingApproval = null;
|
|
933
|
+
setStatus('thinking');
|
|
934
|
+
} else if (type === 'session.idle') {
|
|
935
|
+
finalizeStreamingMsg();
|
|
936
|
+
setStatus('idle');
|
|
937
|
+
} else if (type === 'message.reasoning') {
|
|
938
|
+
const msgsEl = document.getElementById('messages');
|
|
939
|
+
const placeholder = msgsEl.querySelector('.empty-chat');
|
|
940
|
+
if (placeholder) placeholder.remove();
|
|
941
|
+
const thinkEl = document.createElement('div');
|
|
942
|
+
thinkEl.className = 'thinking-block';
|
|
943
|
+
thinkEl.textContent = msg.reasoning || msg.text || '';
|
|
944
|
+
if (!state.showTools) thinkEl.style.display = 'none';
|
|
945
|
+
msgsEl.appendChild(thinkEl);
|
|
946
|
+
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
947
|
+
} else if (type === 'session.error') {
|
|
948
|
+
finalizeStreamingMsg();
|
|
949
|
+
setStatus('idle');
|
|
950
|
+
showToast('\u9519\u8BEF: ' + (msg.error || 'unknown error'), 'error');
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function escHtml(s) {
|
|
955
|
+
return String(s)
|
|
956
|
+
.replace(/&/g, '&')
|
|
957
|
+
.replace(/</g, '<')
|
|
958
|
+
.replace(/>/g, '>');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// \u2500\u2500 Tool box collapse \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
962
|
+
function toggleToolBox(headerEl) {
|
|
963
|
+
const box = headerEl.closest('.tool-box');
|
|
964
|
+
if (!box) return;
|
|
965
|
+
const isNowCollapsed = box.classList.toggle('collapsed');
|
|
966
|
+
const indicator = headerEl.querySelector('.tb-indicator');
|
|
967
|
+
if (indicator) indicator.textContent = isNowCollapsed ? '\u25B6' : '\u25BC';
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// \u2500\u2500 Skill dropdown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
971
|
+
var selectedSkillIndex = -1;
|
|
972
|
+
|
|
973
|
+
async function loadSkills() {
|
|
974
|
+
if (state.skillsLoaded) return;
|
|
975
|
+
try {
|
|
976
|
+
const data = await apiFetch('/api/skills');
|
|
977
|
+
state.skills = Array.isArray(data) ? data : [];
|
|
978
|
+
state.skillsLoaded = true;
|
|
979
|
+
} catch (e) {
|
|
980
|
+
state.skills = [];
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function showSkillDropdown(query) {
|
|
985
|
+
const dropdown = document.getElementById('skill-dropdown');
|
|
986
|
+
if (!dropdown) return;
|
|
987
|
+
const q = query.toLowerCase();
|
|
988
|
+
const filtered = state.skills.filter(function(s) {
|
|
989
|
+
return s.name.toLowerCase().startsWith(q);
|
|
990
|
+
});
|
|
991
|
+
if (!filtered.length) {
|
|
992
|
+
dropdown.style.display = 'none';
|
|
993
|
+
selectedSkillIndex = -1;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
selectedSkillIndex = -1;
|
|
997
|
+
dropdown.innerHTML = filtered.map(function(s, i) {
|
|
998
|
+
return '<div class="skill-item" data-name="' + escHtml(s.name) + '">' +
|
|
999
|
+
'<span class="skill-name">/' + escHtml(s.name) + '</span>' +
|
|
1000
|
+
'<span class="skill-desc">' + escHtml(s.description || '') + '</span>' +
|
|
1001
|
+
'</div>';
|
|
1002
|
+
}).join('');
|
|
1003
|
+
dropdown.querySelectorAll('.skill-item').forEach(function(el) {
|
|
1004
|
+
el.addEventListener('click', function() {
|
|
1005
|
+
var name = el.getAttribute('data-name');
|
|
1006
|
+
var inputEl = document.getElementById('msg-input');
|
|
1007
|
+
inputEl.value = '/' + name + ' ';
|
|
1008
|
+
hideSkillDropdown();
|
|
1009
|
+
inputEl.focus();
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
dropdown.style.display = 'block';
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function hideSkillDropdown() {
|
|
1016
|
+
const dropdown = document.getElementById('skill-dropdown');
|
|
1017
|
+
if (dropdown) dropdown.style.display = 'none';
|
|
1018
|
+
selectedSkillIndex = -1;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function updateSkillSelection() {
|
|
1022
|
+
const dropdown = document.getElementById('skill-dropdown');
|
|
1023
|
+
if (!dropdown) return;
|
|
1024
|
+
const items = dropdown.querySelectorAll('.skill-item');
|
|
1025
|
+
items.forEach(function(el, i) {
|
|
1026
|
+
if (i === selectedSkillIndex) {
|
|
1027
|
+
el.classList.add('selected');
|
|
1028
|
+
} else {
|
|
1029
|
+
el.classList.remove('selected');
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// \u2500\u2500 Approval modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1035
|
+
function showApprovalModal(msg) {
|
|
1036
|
+
const box = document.getElementById('approval-box');
|
|
1037
|
+
box.className = msg.kind === 'danger' ? 'danger' : '';
|
|
1038
|
+
document.getElementById('approval-prompt').textContent =
|
|
1039
|
+
msg.prompt || msg.command || msg.text || JSON.stringify(msg);
|
|
1040
|
+
document.getElementById('approval-modal').classList.add('visible');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function hideApprovalModal() {
|
|
1044
|
+
document.getElementById('approval-modal').classList.remove('visible');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async function sendApproval(approved) {
|
|
1048
|
+
const pending = state.pendingApproval;
|
|
1049
|
+
if (!pending || !state.activeSessionId) return;
|
|
1050
|
+
hideApprovalModal();
|
|
1051
|
+
state.pendingApproval = null;
|
|
1052
|
+
try {
|
|
1053
|
+
await apiFetch('/api/chat/sessions/' + state.activeSessionId + '/approve', {
|
|
1054
|
+
method: 'POST',
|
|
1055
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1056
|
+
body: JSON.stringify({ requestId: pending.requestId || pending.id, approved }),
|
|
1057
|
+
});
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
showToast('\u5BA1\u6279\u8BF7\u6C42\u5931\u8D25: ' + e.message, 'error');
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
document.getElementById('approve-btn').addEventListener('click', () => sendApproval(true));
|
|
1064
|
+
document.getElementById('deny-btn').addEventListener('click', () => sendApproval(false));
|
|
1065
|
+
|
|
1066
|
+
// \u2500\u2500 Session selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1067
|
+
async function selectSession(id) {
|
|
1068
|
+
state.activeSessionId = id;
|
|
1069
|
+
const session = state.sessions.find(s => s.id === id);
|
|
1070
|
+
document.getElementById('chat-title').textContent =
|
|
1071
|
+
session ? (session.title || id.slice(0, 12) + '\u2026') : id;
|
|
1072
|
+
clearMessages();
|
|
1073
|
+
loadMessages(id);
|
|
1074
|
+
renderSidebar();
|
|
1075
|
+
setStatus('idle');
|
|
1076
|
+
// \u5148\u5C1D\u8BD5\u6062\u590D\uFF08\u5C06\u5386\u53F2\u4F1A\u8BDD\u52A0\u8F7D\u5230\u5185\u5B58\uFF09\uFF0C\u518D\u8FDE\u63A5 WS
|
|
1077
|
+
try {
|
|
1078
|
+
await apiFetch('/api/chat/sessions/' + id + '/resume', { method: 'POST' });
|
|
1079
|
+
} catch {
|
|
1080
|
+
// \u6062\u590D\u5931\u8D25\u4E5F\u7EE7\u7EED\uFF08\u53EF\u80FD\u5DF2\u5728\u5185\u5B58\u4E2D\uFF0C\u6216\u53EA\u8BFB\u67E5\u770B\uFF09
|
|
1081
|
+
}
|
|
1082
|
+
connectWs(id);
|
|
1083
|
+
// Close sidebar on mobile
|
|
1084
|
+
document.getElementById('sidebar').classList.remove('open');
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// \u2500\u2500 New session \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1088
|
+
document.getElementById('new-session-btn').addEventListener('click', async () => {
|
|
1089
|
+
try {
|
|
1090
|
+
const data = await apiFetch('/api/chat/sessions', {
|
|
1091
|
+
method: 'POST',
|
|
1092
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1093
|
+
body: JSON.stringify({}),
|
|
1094
|
+
});
|
|
1095
|
+
const session = data.session || data;
|
|
1096
|
+
if (session && session.id) {
|
|
1097
|
+
state.sessions.unshift(session);
|
|
1098
|
+
renderSidebar();
|
|
1099
|
+
selectSession(session.id);
|
|
1100
|
+
}
|
|
1101
|
+
} catch (e) {
|
|
1102
|
+
showToast('\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25: ' + e.message, 'error');
|
|
1103
|
+
if (e.message && e.message.includes('API Key')) {
|
|
1104
|
+
openConfigModal(true);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// \u2500\u2500 Send message \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1110
|
+
async function sendMessage() {
|
|
1111
|
+
if (state.status !== 'idle' || !state.activeSessionId) return;
|
|
1112
|
+
const inputEl = document.getElementById('msg-input');
|
|
1113
|
+
const text = inputEl.value.trim();
|
|
1114
|
+
if (!text) return;
|
|
1115
|
+
inputEl.value = '';
|
|
1116
|
+
inputEl.style.height = 'auto';
|
|
1117
|
+
appendMessage('user', escHtml(text));
|
|
1118
|
+
setStatus('thinking');
|
|
1119
|
+
try {
|
|
1120
|
+
await apiFetch('/api/chat/sessions/' + state.activeSessionId + '/submit', {
|
|
1121
|
+
method: 'POST',
|
|
1122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1123
|
+
body: JSON.stringify({ message: text }),
|
|
1124
|
+
});
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
showToast('\u53D1\u9001\u5931\u8D25: ' + e.message, 'error');
|
|
1127
|
+
setStatus('idle');
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
document.getElementById('send-btn').addEventListener('click', sendMessage);
|
|
1132
|
+
|
|
1133
|
+
document.getElementById('msg-input').addEventListener('keydown', function(e) {
|
|
1134
|
+
const dropdown = document.getElementById('skill-dropdown');
|
|
1135
|
+
const dropdownVisible = dropdown && dropdown.style.display !== 'none';
|
|
1136
|
+
if (dropdownVisible) {
|
|
1137
|
+
const items = dropdown.querySelectorAll('.skill-item');
|
|
1138
|
+
if (e.key === 'ArrowDown') {
|
|
1139
|
+
e.preventDefault();
|
|
1140
|
+
selectedSkillIndex = Math.min(selectedSkillIndex + 1, items.length - 1);
|
|
1141
|
+
updateSkillSelection();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (e.key === 'ArrowUp') {
|
|
1145
|
+
e.preventDefault();
|
|
1146
|
+
selectedSkillIndex = Math.max(selectedSkillIndex - 1, -1);
|
|
1147
|
+
updateSkillSelection();
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (e.key === 'Enter' && selectedSkillIndex >= 0) {
|
|
1151
|
+
e.preventDefault();
|
|
1152
|
+
const selected = items[selectedSkillIndex];
|
|
1153
|
+
if (selected) {
|
|
1154
|
+
var name = selected.getAttribute('data-name');
|
|
1155
|
+
var inputEl = document.getElementById('msg-input');
|
|
1156
|
+
inputEl.value = '/' + name + ' ';
|
|
1157
|
+
hideSkillDropdown();
|
|
1158
|
+
inputEl.focus();
|
|
1159
|
+
}
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (e.key === 'Escape') {
|
|
1163
|
+
e.preventDefault();
|
|
1164
|
+
hideSkillDropdown();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1169
|
+
e.preventDefault();
|
|
1170
|
+
sendMessage();
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
// Auto-resize textarea + slash command dropdown
|
|
1175
|
+
document.getElementById('msg-input').addEventListener('input', function() {
|
|
1176
|
+
this.style.height = 'auto';
|
|
1177
|
+
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
|
1178
|
+
const val = this.value;
|
|
1179
|
+
if (val.startsWith('/')) {
|
|
1180
|
+
loadSkills().then(function() { showSkillDropdown(val.slice(1)); });
|
|
1181
|
+
} else {
|
|
1182
|
+
hideSkillDropdown();
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
// \u2500\u2500 Hamburger (mobile) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1187
|
+
document.getElementById('hamburger').addEventListener('click', () => {
|
|
1188
|
+
document.getElementById('sidebar').classList.toggle('open');
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// \u2500\u2500 \u914D\u7F6E\u6A21\u6001\u6846 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1192
|
+
async function openConfigModal(focusApiKey) {
|
|
1193
|
+
try {
|
|
1194
|
+
const data = await apiFetch('/api/config');
|
|
1195
|
+
document.getElementById('cfg-model').value = data.model || '';
|
|
1196
|
+
document.getElementById('cfg-baseurl').value = data.baseUrl || '';
|
|
1197
|
+
document.getElementById('cfg-logdir').value = data.logDir || '';
|
|
1198
|
+
document.getElementById('cfg-systemprompt').value = data.systemPrompt || '';
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
showToast('\u52A0\u8F7D\u914D\u7F6E\u5931\u8D25: ' + e.message, 'error');
|
|
1201
|
+
}
|
|
1202
|
+
document.getElementById('config-modal').classList.add('open');
|
|
1203
|
+
if (focusApiKey) {
|
|
1204
|
+
setTimeout(() => document.getElementById('cfg-apikey').focus(), 50);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
document.getElementById('config-btn').addEventListener('click', () => openConfigModal(false));
|
|
1209
|
+
|
|
1210
|
+
document.getElementById('cancel-config').addEventListener('click', () => {
|
|
1211
|
+
document.getElementById('config-modal').classList.remove('open');
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
document.getElementById('save-config').addEventListener('click', async () => {
|
|
1215
|
+
const body = {
|
|
1216
|
+
model: document.getElementById('cfg-model').value,
|
|
1217
|
+
baseUrl: document.getElementById('cfg-baseurl').value,
|
|
1218
|
+
logDir: document.getElementById('cfg-logdir').value,
|
|
1219
|
+
systemPrompt: document.getElementById('cfg-systemprompt').value,
|
|
1220
|
+
};
|
|
1221
|
+
const apiKeyVal = document.getElementById('cfg-apikey').value;
|
|
1222
|
+
if (apiKeyVal) body.apiKey = apiKeyVal;
|
|
1223
|
+
try {
|
|
1224
|
+
await apiFetch('/api/config', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
1225
|
+
updateTopbarModel(body.model);
|
|
1226
|
+
showToast('\u914D\u7F6E\u5DF2\u4FDD\u5B58', 'success');
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
showToast('\u4FDD\u5B58\u5931\u8D25: ' + e.message, 'error');
|
|
1229
|
+
}
|
|
1230
|
+
document.getElementById('config-modal').classList.remove('open');
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// \u2500\u2500 \u5347\u7EA7 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1234
|
+
document.getElementById('upgrade-btn').addEventListener('click', async () => {
|
|
1235
|
+
const modal = document.getElementById('upgrade-modal');
|
|
1236
|
+
const statusEl = document.getElementById('upgrade-status');
|
|
1237
|
+
const outputEl = document.getElementById('upgrade-output');
|
|
1238
|
+
const confirmBtn = document.getElementById('confirm-upgrade');
|
|
1239
|
+
outputEl.style.display = 'none';
|
|
1240
|
+
outputEl.textContent = '';
|
|
1241
|
+
confirmBtn.disabled = true;
|
|
1242
|
+
statusEl.textContent = '\u6B63\u5728\u68C0\u67E5\u7248\u672C\u2026';
|
|
1243
|
+
modal.classList.add('open');
|
|
1244
|
+
try {
|
|
1245
|
+
const r = await fetch(apiUrl('/api/version/check'));
|
|
1246
|
+
const v = await r.json();
|
|
1247
|
+
if (v.needsUpdate) {
|
|
1248
|
+
statusEl.textContent = '\u5F53\u524D v' + v.current + '\uFF0C\u6700\u65B0 v' + v.latest + '\uFF0C\u786E\u8BA4\u5347\u7EA7\uFF1F';
|
|
1249
|
+
confirmBtn.disabled = false;
|
|
1250
|
+
} else {
|
|
1251
|
+
statusEl.textContent = '\u5DF2\u662F\u6700\u65B0\u7248\u672C v' + v.current;
|
|
1252
|
+
}
|
|
1253
|
+
} catch(e) {
|
|
1254
|
+
statusEl.textContent = '\u7248\u672C\u68C0\u67E5\u5931\u8D25\uFF1A' + e.message;
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
document.getElementById('cancel-upgrade').addEventListener('click', () => {
|
|
1259
|
+
document.getElementById('upgrade-modal').classList.remove('open');
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
document.getElementById('confirm-upgrade').addEventListener('click', async () => {
|
|
1263
|
+
const outputEl = document.getElementById('upgrade-output');
|
|
1264
|
+
const confirmBtn = document.getElementById('confirm-upgrade');
|
|
1265
|
+
const statusEl = document.getElementById('upgrade-status');
|
|
1266
|
+
confirmBtn.disabled = true;
|
|
1267
|
+
outputEl.style.display = 'block';
|
|
1268
|
+
outputEl.textContent = '';
|
|
1269
|
+
statusEl.textContent = '\u5347\u7EA7\u4E2D\u2026';
|
|
1270
|
+
try {
|
|
1271
|
+
const r = await fetch(apiUrl('/api/system/upgrade'), { method: 'POST' });
|
|
1272
|
+
const reader = r.body.getReader();
|
|
1273
|
+
const decoder = new TextDecoder();
|
|
1274
|
+
while (true) {
|
|
1275
|
+
const { done, value } = await reader.read();
|
|
1276
|
+
if (done) break;
|
|
1277
|
+
outputEl.textContent += decoder.decode(value);
|
|
1278
|
+
outputEl.scrollTop = outputEl.scrollHeight;
|
|
1279
|
+
}
|
|
1280
|
+
statusEl.textContent = '\u5347\u7EA7\u5B8C\u6210\uFF0C\u8BF7\u91CD\u542F ecode web';
|
|
1281
|
+
} catch(e) {
|
|
1282
|
+
statusEl.textContent = '\u5347\u7EA7\u5931\u8D25\uFF1A' + e.message;
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// \u2500\u2500 \u5DE5\u5177/\u601D\u8003\u53EF\u89C1\u6027\u5207\u6362 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1287
|
+
document.getElementById('toggle-tools-btn').addEventListener('click', () => {
|
|
1288
|
+
state.showTools = !state.showTools;
|
|
1289
|
+
document.getElementById('toggle-tools-btn').textContent = state.showTools ? '\u5DE5\u5177 \u25BE' : '\u5DE5\u5177 \u25B8';
|
|
1290
|
+
document.querySelectorAll('.tool-box, .thinking-block').forEach(el => {
|
|
1291
|
+
el.style.display = state.showTools ? '' : 'none';
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// \u2500\u2500 Skill dropdown: hide on outside click \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1296
|
+
document.addEventListener('click', function(e) {
|
|
1297
|
+
if (!e.target.closest('#skill-dropdown') && !e.target.closest('#msg-input')) {
|
|
1298
|
+
hideSkillDropdown();
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// \u2500\u2500 Mobile keyboard adaptation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1303
|
+
document.getElementById('msg-input').addEventListener('focus', () => {
|
|
1304
|
+
setTimeout(() => {
|
|
1305
|
+
document.getElementById('msg-input').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
1306
|
+
}, 300);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
if (window.visualViewport) {
|
|
1310
|
+
window.visualViewport.addEventListener('resize', function() {
|
|
1311
|
+
const bar = document.getElementById('input-bar');
|
|
1312
|
+
if (!bar) return;
|
|
1313
|
+
const offsetBottom = window.innerHeight - window.visualViewport.height - window.visualViewport.offsetTop;
|
|
1314
|
+
bar.style.bottom = Math.max(0, offsetBottom) + 'px';
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// \u2500\u2500 Global error boundary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1319
|
+
window.onerror = function(msg, src, line) {
|
|
1320
|
+
const wsStatusEl = document.getElementById('ws-status');
|
|
1321
|
+
if (wsStatusEl) wsStatusEl.textContent = '\u9519\u8BEF: ' + msg;
|
|
1322
|
+
return false;
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1326
|
+
function updateTopbarModel(model) {
|
|
1327
|
+
const el = document.getElementById('topbar-model');
|
|
1328
|
+
if (!el) return;
|
|
1329
|
+
if (model) {
|
|
1330
|
+
el.textContent = model;
|
|
1331
|
+
el.style.display = '';
|
|
1332
|
+
} else {
|
|
1333
|
+
el.style.display = 'none';
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function initModel() {
|
|
1338
|
+
try {
|
|
1339
|
+
const data = await apiFetch('/api/config');
|
|
1340
|
+
updateTopbarModel(data.model);
|
|
1341
|
+
} catch {
|
|
1342
|
+
// non-critical, ignore
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
loadSessions();
|
|
1347
|
+
initModel();
|
|
1348
|
+
</script>
|
|
1349
|
+
</body>
|
|
1350
|
+
</html>`;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// src/web/routes/status.ts
|
|
1354
|
+
async function statusRoutes(app, opts) {
|
|
1355
|
+
app.get(
|
|
1356
|
+
"/api/status",
|
|
1357
|
+
async (_request, _reply) => {
|
|
1358
|
+
const runningSessions = opts.manager.listRunning().length;
|
|
1359
|
+
return {
|
|
1360
|
+
version: opts.version,
|
|
1361
|
+
cwd: process.cwd(),
|
|
1362
|
+
model: opts.config.model,
|
|
1363
|
+
logDir: opts.config.logDir,
|
|
1364
|
+
defaultProvider: opts.config.defaultProvider,
|
|
1365
|
+
runningSessions
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// src/web/routes/sessions.ts
|
|
1372
|
+
import { readFileSync, existsSync } from "fs";
|
|
1373
|
+
import { join } from "path";
|
|
1374
|
+
async function sessionsRoutes(app, opts) {
|
|
1375
|
+
app.get("/api/sessions", async (_request, _reply) => {
|
|
1376
|
+
const fileSessions = opts.config.logDir ? listSessions(opts.config.logDir) : [];
|
|
1377
|
+
if (!opts.manager) {
|
|
1378
|
+
return fileSessions;
|
|
1379
|
+
}
|
|
1380
|
+
const runningSnapshots = opts.manager.listRunning();
|
|
1381
|
+
const fileIds = new Set(fileSessions.map((s) => s.id));
|
|
1382
|
+
const runtimeSessions = runningSnapshots.filter((s) => !fileIds.has(s.id)).map((s) => ({
|
|
1383
|
+
id: s.id,
|
|
1384
|
+
title: s.title,
|
|
1385
|
+
model: s.model,
|
|
1386
|
+
status: s.status,
|
|
1387
|
+
turnCount: s.turnCount,
|
|
1388
|
+
totalTokens: s.totalTokens,
|
|
1389
|
+
startTime: s.startedAt,
|
|
1390
|
+
lastActivity: s.lastActivity,
|
|
1391
|
+
cwd: "",
|
|
1392
|
+
logFile: ""
|
|
1393
|
+
}));
|
|
1394
|
+
return [...fileSessions, ...runtimeSessions];
|
|
1395
|
+
});
|
|
1396
|
+
app.get(
|
|
1397
|
+
"/api/sessions/:id/messages",
|
|
1398
|
+
async (request, reply) => {
|
|
1399
|
+
const { id } = request.params;
|
|
1400
|
+
if (opts.config.logDir) {
|
|
1401
|
+
const session = findSession(opts.config.logDir, id);
|
|
1402
|
+
if (session) {
|
|
1403
|
+
const logFilePath = join(opts.config.logDir, session.logFile);
|
|
1404
|
+
if (existsSync(logFilePath)) {
|
|
1405
|
+
try {
|
|
1406
|
+
const content = readFileSync(logFilePath, "utf-8");
|
|
1407
|
+
return reply.header("Content-Type", "text/plain; charset=utf-8").send(content);
|
|
1408
|
+
} catch {
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (opts.manager && opts.manager.getSession(id)) {
|
|
1414
|
+
return reply.header("Content-Type", "text/plain; charset=utf-8").send("");
|
|
1415
|
+
}
|
|
1416
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1417
|
+
}
|
|
1418
|
+
);
|
|
1419
|
+
app.get(
|
|
1420
|
+
"/api/sessions/:id/replay-command",
|
|
1421
|
+
async (request, reply) => {
|
|
1422
|
+
const { id } = request.params;
|
|
1423
|
+
if (!opts.config.logDir) {
|
|
1424
|
+
return reply.code(404).send({ success: false, error: "Log directory not configured" });
|
|
1425
|
+
}
|
|
1426
|
+
const logDir = opts.config.logDir;
|
|
1427
|
+
const session = findSession(logDir, id);
|
|
1428
|
+
if (!session) {
|
|
1429
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1430
|
+
}
|
|
1431
|
+
const command = cmdSessionsReplay(logDir, id);
|
|
1432
|
+
return reply.send({ success: true, command });
|
|
1433
|
+
}
|
|
1434
|
+
);
|
|
1435
|
+
app.delete(
|
|
1436
|
+
"/api/sessions/:id",
|
|
1437
|
+
async (request, reply) => {
|
|
1438
|
+
const { id } = request.params;
|
|
1439
|
+
if (!opts.config.logDir) {
|
|
1440
|
+
return reply.code(404).send({ success: false, error: "Log directory not configured" });
|
|
1441
|
+
}
|
|
1442
|
+
const deleted = deleteSessionFiles(opts.config.logDir, id);
|
|
1443
|
+
if (!deleted) {
|
|
1444
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1445
|
+
}
|
|
1446
|
+
if (opts.manager) {
|
|
1447
|
+
opts.manager.deleteSession(id);
|
|
1448
|
+
}
|
|
1449
|
+
return reply.send({ success: true });
|
|
1450
|
+
}
|
|
1451
|
+
);
|
|
1452
|
+
app.get(
|
|
1453
|
+
"/api/sessions/:id/fork-command",
|
|
1454
|
+
async (request, reply) => {
|
|
1455
|
+
const { id } = request.params;
|
|
1456
|
+
if (!opts.config.logDir) {
|
|
1457
|
+
return reply.code(404).send({ success: false, error: "Log directory not configured" });
|
|
1458
|
+
}
|
|
1459
|
+
const logDir = opts.config.logDir;
|
|
1460
|
+
const session = findSession(logDir, id);
|
|
1461
|
+
if (!session) {
|
|
1462
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1463
|
+
}
|
|
1464
|
+
let turn;
|
|
1465
|
+
if (request.query.turn !== void 0) {
|
|
1466
|
+
const parsed = parseInt(request.query.turn, 10);
|
|
1467
|
+
if (isNaN(parsed)) {
|
|
1468
|
+
return reply.code(400).send({ success: false, error: `Invalid turn value: ${request.query.turn}` });
|
|
1469
|
+
}
|
|
1470
|
+
turn = parsed;
|
|
1471
|
+
}
|
|
1472
|
+
const command = cmdSessionsFork(logDir, id, turn);
|
|
1473
|
+
return reply.send({ success: true, command });
|
|
1474
|
+
}
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/web/routes/config.ts
|
|
1479
|
+
function toSafeConfig(cfg) {
|
|
1480
|
+
const { apiKey: _stripped, ...safe } = cfg;
|
|
1481
|
+
return safe;
|
|
1482
|
+
}
|
|
1483
|
+
async function configRoutes(app, opts) {
|
|
1484
|
+
app.get("/api/config", async (_request, _reply) => {
|
|
1485
|
+
const { apiKey: _stripped, ...safeConfig } = opts.config;
|
|
1486
|
+
return safeConfig;
|
|
1487
|
+
});
|
|
1488
|
+
app.put(
|
|
1489
|
+
"/api/config",
|
|
1490
|
+
async (request, reply) => {
|
|
1491
|
+
var _a;
|
|
1492
|
+
const body = request.body;
|
|
1493
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
1494
|
+
return reply.status(400).send(toSafeConfig(opts.config));
|
|
1495
|
+
}
|
|
1496
|
+
const merged = { ...opts.config, ...body };
|
|
1497
|
+
(_a = opts.save) == null ? void 0 : _a.call(opts, merged);
|
|
1498
|
+
Object.assign(opts.config, merged);
|
|
1499
|
+
return toSafeConfig(merged);
|
|
1500
|
+
}
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// src/web/routes/automation.ts
|
|
1505
|
+
async function automationRoutes(app, opts) {
|
|
1506
|
+
app.get(
|
|
1507
|
+
"/api/automation/jobs",
|
|
1508
|
+
async (_request, reply) => {
|
|
1509
|
+
if (!opts.config.logDir) {
|
|
1510
|
+
return reply.send([]);
|
|
1511
|
+
}
|
|
1512
|
+
const jobs = await loadJobs(opts.config.logDir);
|
|
1513
|
+
return jobs;
|
|
1514
|
+
}
|
|
1515
|
+
);
|
|
1516
|
+
app.post(
|
|
1517
|
+
"/api/automation/jobs/:id/pause",
|
|
1518
|
+
async (request, reply) => {
|
|
1519
|
+
const logDir = opts.config.logDir;
|
|
1520
|
+
if (!logDir) {
|
|
1521
|
+
return reply.status(404).send({ error: "logDir not configured" });
|
|
1522
|
+
}
|
|
1523
|
+
const { id } = request.params;
|
|
1524
|
+
const jobs = await loadJobs(logDir);
|
|
1525
|
+
const job = jobs.find((j) => j.id === id);
|
|
1526
|
+
if (!job) {
|
|
1527
|
+
return reply.status(404).send({ error: "job not found" });
|
|
1528
|
+
}
|
|
1529
|
+
if (job.state === "paused") {
|
|
1530
|
+
return reply.status(409).send({ error: "job is already paused" });
|
|
1531
|
+
}
|
|
1532
|
+
const updated = {
|
|
1533
|
+
...job,
|
|
1534
|
+
state: "paused",
|
|
1535
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1536
|
+
};
|
|
1537
|
+
await upsertJob(logDir, updated);
|
|
1538
|
+
return reply.status(200).send({ success: true });
|
|
1539
|
+
}
|
|
1540
|
+
);
|
|
1541
|
+
app.post(
|
|
1542
|
+
"/api/automation/jobs/:id/resume",
|
|
1543
|
+
async (request, reply) => {
|
|
1544
|
+
const logDir = opts.config.logDir;
|
|
1545
|
+
if (!logDir) {
|
|
1546
|
+
return reply.status(404).send({ error: "logDir not configured" });
|
|
1547
|
+
}
|
|
1548
|
+
const { id } = request.params;
|
|
1549
|
+
const jobs = await loadJobs(logDir);
|
|
1550
|
+
const job = jobs.find((j) => j.id === id);
|
|
1551
|
+
if (!job) {
|
|
1552
|
+
return reply.status(404).send({ error: "job not found" });
|
|
1553
|
+
}
|
|
1554
|
+
if (job.state !== "paused") {
|
|
1555
|
+
return reply.status(409).send({ error: "job is not paused" });
|
|
1556
|
+
}
|
|
1557
|
+
const updated = {
|
|
1558
|
+
...job,
|
|
1559
|
+
state: "scheduled",
|
|
1560
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1561
|
+
};
|
|
1562
|
+
await upsertJob(logDir, updated);
|
|
1563
|
+
return reply.status(200).send({ success: true });
|
|
1564
|
+
}
|
|
1565
|
+
);
|
|
1566
|
+
app.delete(
|
|
1567
|
+
"/api/automation/jobs/:id",
|
|
1568
|
+
async (request, reply) => {
|
|
1569
|
+
const logDir = opts.config.logDir;
|
|
1570
|
+
if (!logDir) {
|
|
1571
|
+
return reply.status(404).send({ error: "logDir not configured" });
|
|
1572
|
+
}
|
|
1573
|
+
const { id } = request.params;
|
|
1574
|
+
const removed = await removeJob(logDir, id);
|
|
1575
|
+
if (!removed) {
|
|
1576
|
+
return reply.status(404).send({ error: "job not found" });
|
|
1577
|
+
}
|
|
1578
|
+
return reply.status(200).send({ success: true });
|
|
1579
|
+
}
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// src/web/routes/chat.ts
|
|
1584
|
+
import { join as join2 } from "path";
|
|
1585
|
+
var BUSY_STATUSES = /* @__PURE__ */ new Set(["thinking", "tool_calling", "awaiting_confirm"]);
|
|
1586
|
+
async function chatRoutes(app, opts) {
|
|
1587
|
+
const { manager } = opts;
|
|
1588
|
+
app.post("/api/chat/sessions", async (request, reply) => {
|
|
1589
|
+
const body = request.body ?? {};
|
|
1590
|
+
const sessionOpts = {};
|
|
1591
|
+
if (typeof body.systemPrompt === "string") {
|
|
1592
|
+
sessionOpts.systemPrompt = body.systemPrompt;
|
|
1593
|
+
}
|
|
1594
|
+
if (opts.autoApprove) {
|
|
1595
|
+
sessionOpts.autoApproveNormal = true;
|
|
1596
|
+
}
|
|
1597
|
+
if (!opts.config.apiKey) {
|
|
1598
|
+
return reply.code(400).send({
|
|
1599
|
+
success: false,
|
|
1600
|
+
error: "\u672A\u914D\u7F6E API Key\uFF0C\u8BF7\u5148\u70B9\u51FB\u53F3\u4E0A\u89D2\u300C\u914D\u7F6E\u300D\u6309\u94AE\u586B\u5199"
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
try {
|
|
1604
|
+
const runtime = await manager.createSession(sessionOpts);
|
|
1605
|
+
return reply.code(201).send({ success: true, session: runtime.snapshot() });
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1608
|
+
return reply.code(500).send({ success: false, error: msg });
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
app.post("/api/chat/sessions/:id/submit", async (request, reply) => {
|
|
1612
|
+
const { id } = request.params;
|
|
1613
|
+
const body = request.body ?? {};
|
|
1614
|
+
if (typeof body.message !== "string" || body.message.trim() === "") {
|
|
1615
|
+
return reply.code(400).send({ success: false, error: "message is required and must be a non-empty string" });
|
|
1616
|
+
}
|
|
1617
|
+
const session = manager.getSession(id);
|
|
1618
|
+
if (!session) {
|
|
1619
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1620
|
+
}
|
|
1621
|
+
if (BUSY_STATUSES.has(session.status)) {
|
|
1622
|
+
return reply.code(409).send({ success: false, error: `Session is busy: ${session.status}` });
|
|
1623
|
+
}
|
|
1624
|
+
session.submit(body.message).catch((err) => {
|
|
1625
|
+
app.log.error({ err, sessionId: id }, "submit \u610F\u5916\u5931\u8D25");
|
|
1626
|
+
});
|
|
1627
|
+
return reply.code(202).send({ success: true, queued: true });
|
|
1628
|
+
});
|
|
1629
|
+
app.post(
|
|
1630
|
+
"/api/chat/sessions/:id/interrupt",
|
|
1631
|
+
async (request, reply) => {
|
|
1632
|
+
const { id } = request.params;
|
|
1633
|
+
const session = manager.getSession(id);
|
|
1634
|
+
if (!session) {
|
|
1635
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1636
|
+
}
|
|
1637
|
+
session.interrupt();
|
|
1638
|
+
return reply.send({ success: true });
|
|
1639
|
+
}
|
|
1640
|
+
);
|
|
1641
|
+
app.post(
|
|
1642
|
+
"/api/chat/sessions/:id/resume",
|
|
1643
|
+
async (request, reply) => {
|
|
1644
|
+
const { id } = request.params;
|
|
1645
|
+
if (manager.getSession(id)) {
|
|
1646
|
+
return reply.send({ success: true, resumed: false });
|
|
1647
|
+
}
|
|
1648
|
+
if (!opts.config.apiKey) {
|
|
1649
|
+
return reply.code(400).send({
|
|
1650
|
+
success: false,
|
|
1651
|
+
error: "\u672A\u914D\u7F6E API Key\uFF0C\u8BF7\u5148\u70B9\u51FB\u53F3\u4E0A\u89D2\u300C\u914D\u7F6E\u300D\u6309\u94AE\u586B\u5199"
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
if (!opts.config.logDir) {
|
|
1655
|
+
return reply.code(404).send({ success: false, error: "Log directory not configured" });
|
|
1656
|
+
}
|
|
1657
|
+
const session = findSession(opts.config.logDir, id);
|
|
1658
|
+
if (!session) {
|
|
1659
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1660
|
+
}
|
|
1661
|
+
const logFilePath = join2(opts.config.logDir, session.logFile);
|
|
1662
|
+
const initialMessages = loadMessagesFromJsonl(logFilePath);
|
|
1663
|
+
try {
|
|
1664
|
+
await manager.createSession({ sessionId: id, initialMessages });
|
|
1665
|
+
return reply.send({ success: true, resumed: true });
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1668
|
+
return reply.code(500).send({ success: false, error: msg });
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
);
|
|
1672
|
+
app.post(
|
|
1673
|
+
"/api/chat/sessions/:id/approve",
|
|
1674
|
+
async (request, reply) => {
|
|
1675
|
+
const { id } = request.params;
|
|
1676
|
+
const body = request.body ?? {};
|
|
1677
|
+
if (typeof body.requestId !== "string") {
|
|
1678
|
+
return reply.code(400).send({ success: false, error: "requestId is required and must be a string" });
|
|
1679
|
+
}
|
|
1680
|
+
if (typeof body.approved !== "boolean") {
|
|
1681
|
+
return reply.code(400).send({ success: false, error: "approved is required and must be a boolean" });
|
|
1682
|
+
}
|
|
1683
|
+
const session = manager.getSession(id);
|
|
1684
|
+
if (!session) {
|
|
1685
|
+
return reply.code(404).send({ success: false, error: `Session not found: ${id}` });
|
|
1686
|
+
}
|
|
1687
|
+
if (session.status !== "awaiting_confirm") {
|
|
1688
|
+
return reply.code(409).send({
|
|
1689
|
+
success: false,
|
|
1690
|
+
error: `Session is not awaiting confirmation: ${session.status}`
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
session.approve(body.requestId, body.approved);
|
|
1694
|
+
return reply.send({ success: true });
|
|
1695
|
+
}
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// src/web/ws/session-hub.ts
|
|
1700
|
+
async function sessionHubRoutes(app, opts) {
|
|
1701
|
+
app.get(
|
|
1702
|
+
"/api/ws/sessions/:id",
|
|
1703
|
+
{ websocket: true },
|
|
1704
|
+
(socket, request) => {
|
|
1705
|
+
const { id } = request.params;
|
|
1706
|
+
const session = opts.manager.getSession(id);
|
|
1707
|
+
if (!session) {
|
|
1708
|
+
socket.close(4404, "session not found");
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const unsubscribe = session.subscribe((event) => {
|
|
1712
|
+
if (socket.readyState === socket.OPEN) {
|
|
1713
|
+
socket.send(JSON.stringify(event));
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
socket.on("close", () => {
|
|
1717
|
+
unsubscribe();
|
|
1718
|
+
});
|
|
1719
|
+
socket.on("message", (_msg) => {
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/web/routes/system.ts
|
|
1726
|
+
import { execSync } from "child_process";
|
|
1727
|
+
import { spawn } from "child_process";
|
|
1728
|
+
async function systemRoutes(app, opts) {
|
|
1729
|
+
app.get("/api/version/check", async (_request, _reply) => {
|
|
1730
|
+
const current = opts.version;
|
|
1731
|
+
let latest;
|
|
1732
|
+
try {
|
|
1733
|
+
latest = execSync("npm view @zhongqian97-code/ecode version", {
|
|
1734
|
+
encoding: "utf-8",
|
|
1735
|
+
timeout: 5e3
|
|
1736
|
+
}).trim();
|
|
1737
|
+
} catch {
|
|
1738
|
+
latest = "unknown";
|
|
1739
|
+
}
|
|
1740
|
+
const needsUpdate = latest !== "unknown" && latest !== current;
|
|
1741
|
+
return { current, latest, needsUpdate };
|
|
1742
|
+
});
|
|
1743
|
+
app.post("/api/system/upgrade", (_request, reply) => {
|
|
1744
|
+
reply.raw.setHeader("Content-Type", "text/plain");
|
|
1745
|
+
reply.raw.write("Upgrading @zhongqian97-code/ecode...\n");
|
|
1746
|
+
const child = spawn("npm", ["update", "-g", "@zhongqian97-code/ecode"]);
|
|
1747
|
+
child.stdout.on("data", (chunk) => {
|
|
1748
|
+
reply.raw.write(chunk);
|
|
1749
|
+
});
|
|
1750
|
+
child.stderr.on("data", (chunk) => {
|
|
1751
|
+
reply.raw.write(chunk);
|
|
1752
|
+
});
|
|
1753
|
+
child.on("close", () => {
|
|
1754
|
+
reply.raw.write("Upgrade complete. Please restart ecode web.\n");
|
|
1755
|
+
reply.raw.end();
|
|
1756
|
+
});
|
|
1757
|
+
return reply;
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// src/web/routes/skills.ts
|
|
1762
|
+
import { readdir, readFile } from "fs/promises";
|
|
1763
|
+
import { join as join3 } from "path";
|
|
1764
|
+
import os from "os";
|
|
1765
|
+
var BUILTIN_COMMANDS = [
|
|
1766
|
+
{ name: "clear", description: "Clear conversation history", type: "builtin" },
|
|
1767
|
+
{ name: "interrupt", description: "Interrupt current operation", type: "builtin" },
|
|
1768
|
+
{ name: "help", description: "Show help information", type: "builtin" }
|
|
1769
|
+
];
|
|
1770
|
+
function parseFrontmatter(content) {
|
|
1771
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1772
|
+
if (!match) return null;
|
|
1773
|
+
const frontmatter = match[1];
|
|
1774
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
1775
|
+
if (!nameMatch) return null;
|
|
1776
|
+
const descMatch = frontmatter.match(/^description:\s*(?:>-?|[|>+])?\s*(.+)$/m);
|
|
1777
|
+
if (!descMatch) return null;
|
|
1778
|
+
const name = nameMatch[1].trim();
|
|
1779
|
+
const description = descMatch[1].trim();
|
|
1780
|
+
if (!name || !description) return null;
|
|
1781
|
+
return { name, description };
|
|
1782
|
+
}
|
|
1783
|
+
async function loadAgents() {
|
|
1784
|
+
const agentsDir = join3(os.homedir(), ".claude", "agents");
|
|
1785
|
+
let files;
|
|
1786
|
+
try {
|
|
1787
|
+
const entries = await readdir(agentsDir);
|
|
1788
|
+
files = entries.filter((f) => f.endsWith(".md"));
|
|
1789
|
+
} catch {
|
|
1790
|
+
return [];
|
|
1791
|
+
}
|
|
1792
|
+
const items = [];
|
|
1793
|
+
for (const file of files) {
|
|
1794
|
+
try {
|
|
1795
|
+
const content = await readFile(join3(agentsDir, file), "utf-8");
|
|
1796
|
+
const parsed = parseFrontmatter(content);
|
|
1797
|
+
if (parsed) {
|
|
1798
|
+
items.push({ ...parsed, type: "agent" });
|
|
1799
|
+
}
|
|
1800
|
+
} catch {
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return items;
|
|
1804
|
+
}
|
|
1805
|
+
async function loadSkills() {
|
|
1806
|
+
const skillsDir = join3(process.cwd(), "skills");
|
|
1807
|
+
let subdirs;
|
|
1808
|
+
try {
|
|
1809
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
1810
|
+
subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
1811
|
+
} catch {
|
|
1812
|
+
return [];
|
|
1813
|
+
}
|
|
1814
|
+
const items = [];
|
|
1815
|
+
for (const subdir of subdirs) {
|
|
1816
|
+
try {
|
|
1817
|
+
const skillFile = join3(skillsDir, subdir, "SKILL.md");
|
|
1818
|
+
const content = await readFile(skillFile, "utf-8");
|
|
1819
|
+
const parsed = parseFrontmatter(content);
|
|
1820
|
+
if (parsed) {
|
|
1821
|
+
items.push({ ...parsed, type: "skill" });
|
|
1822
|
+
}
|
|
1823
|
+
} catch {
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return items;
|
|
1827
|
+
}
|
|
1828
|
+
async function skillsRoutes(app) {
|
|
1829
|
+
app.get("/api/skills", async (_request, _reply) => {
|
|
1830
|
+
const [agents, skills] = await Promise.all([loadAgents(), loadSkills()]);
|
|
1831
|
+
return [...BUILTIN_COMMANDS, ...agents, ...skills];
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// src/web/server.ts
|
|
1836
|
+
async function buildServer(opts) {
|
|
1837
|
+
const app = Fastify({ logger: false });
|
|
1838
|
+
await app.register(cors, { origin: true });
|
|
1839
|
+
await app.register(websocket);
|
|
1840
|
+
const authHook = createAuthHook(opts.token);
|
|
1841
|
+
app.addHook("preHandler", function(request, reply, done) {
|
|
1842
|
+
if (request.url === "/health") {
|
|
1843
|
+
done();
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
authHook(request, reply, done);
|
|
1847
|
+
});
|
|
1848
|
+
app.get("/health", async () => ({ ok: true }));
|
|
1849
|
+
const adminHtml = generateAdminHtml(opts.version);
|
|
1850
|
+
app.get("/", async (_req, reply) => {
|
|
1851
|
+
return reply.type("text/html").send(adminHtml);
|
|
1852
|
+
});
|
|
1853
|
+
await app.register(statusRoutes, {
|
|
1854
|
+
config: opts.config,
|
|
1855
|
+
manager: opts.manager,
|
|
1856
|
+
version: opts.version
|
|
1857
|
+
});
|
|
1858
|
+
await app.register(sessionsRoutes, { config: opts.config, manager: opts.manager });
|
|
1859
|
+
await app.register(configRoutes, { config: opts.config, save: saveConfig });
|
|
1860
|
+
await app.register(automationRoutes, { config: opts.config });
|
|
1861
|
+
await app.register(chatRoutes, { config: opts.config, manager: opts.manager, autoApprove: opts.autoApprove });
|
|
1862
|
+
await app.register(systemRoutes, { version: opts.version });
|
|
1863
|
+
await app.register(skillsRoutes);
|
|
1864
|
+
await app.register(sessionHubRoutes, { manager: opts.manager });
|
|
1865
|
+
return app;
|
|
1866
|
+
}
|
|
1867
|
+
export {
|
|
1868
|
+
buildServer,
|
|
1869
|
+
generateAccessToken
|
|
1870
|
+
};
|